mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-06-04 01:22:42 +03:00
Compare commits
45 Commits
FullSpectr
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3546dafc18 | ||
|
|
2e4cbd4511 | ||
|
|
411787afb2 | ||
|
|
00bb52bcd7 | ||
|
|
1a960b59ea | ||
|
|
9a16fb7c2e | ||
|
|
7ef89fdb9d | ||
|
|
3d813d529e | ||
|
|
151927ac00 | ||
|
|
3f1a2a71bd | ||
|
|
b87dd23c74 | ||
|
|
cea46ddc7f | ||
|
|
b3d7a732c5 | ||
|
|
b70be9178e | ||
|
|
892b33bac5 | ||
|
|
952696fd1f | ||
|
|
6980d9c327 | ||
|
|
45e93951c1 | ||
|
|
a4cedde163 | ||
|
|
61b4131aee | ||
|
|
6eb479243d | ||
|
|
622272e674 | ||
|
|
b54cc75362 | ||
|
|
9d915c4e76 | ||
|
|
d742b10c50 | ||
|
|
47467b626c | ||
|
|
c0d37bff3a | ||
|
|
39a29cf865 | ||
|
|
487e1cb205 | ||
|
|
a2e8a90052 | ||
|
|
8dcbc582fa | ||
|
|
b0325c999a | ||
|
|
5a2f03adee | ||
|
|
a8ed2b8dd5 | ||
|
|
aac14ae161 | ||
|
|
94c356845e | ||
|
|
e449a0b618 | ||
|
|
ddd1967bff | ||
|
|
02140d2a1e | ||
|
|
0be138b981 | ||
|
|
11301086a7 | ||
|
|
1b72dbf6fa | ||
|
|
ac92125012 | ||
|
|
d6a49ace15 | ||
|
|
71eebc2332 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,4 +45,5 @@ test.js
|
||||
.clangd
|
||||
internal_docs/
|
||||
*.flatpak
|
||||
/flatpak-repo/
|
||||
/flatpak-repo/
|
||||
*.pyc
|
||||
668
doc/automation.md
Normal file
668
doc/automation.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# OrcaSlicer UI Automation Protocol (v1.0.0)
|
||||
|
||||
OrcaSlicer ships an **opt-in, localhost-only JSON-RPC server** that lets external
|
||||
scripts introspect, drive, and screenshot the running OrcaSlicer GUI. It is built
|
||||
for end-to-end testing and automation: a script can enumerate the live widget
|
||||
tree, click buttons, type text, send keyboard shortcuts, wait for UI state, query
|
||||
high-level application state, load models/projects into the running instance,
|
||||
switch the active view/tab, and capture window images (the on-screen capture
|
||||
includes the 3D viewport).
|
||||
|
||||
This document is the protocol reference. It describes activation, the transport,
|
||||
the JSON-RPC envelope, every method, the unified node shape, the target/locator
|
||||
model, error codes, the set of instrumented automation ids, ImGui specifics,
|
||||
platform caveats, a quick-start snippet, and planned future work.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & activation
|
||||
|
||||
The automation server is **OFF by default**. It is enabled with two
|
||||
command-line flags:
|
||||
|
||||
| Flag | Meaning |
|
||||
|---|---|
|
||||
| `--automation-server` | Enable the automation server. |
|
||||
| `--automation-server-port=PORT` | Override the listening port. Optional; default is **13619**. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
OrcaSlicer --automation-server --automation-server-port=13619 model.stl
|
||||
```
|
||||
|
||||
The server binds to **`127.0.0.1` only** (the loopback interface). It is never
|
||||
exposed on an external network interface.
|
||||
|
||||
**Security note (v1):** there is **no authentication token** in v1. The localhost
|
||||
bind is the *only* security boundary. Any process able to run code on the machine
|
||||
can connect to the port and drive the GUI — including injecting mouse and keyboard
|
||||
input — while the server is enabled. The feature is intended for testing and
|
||||
automation environments, not for production or shared/multi-user machines.
|
||||
|
||||
When the server is enabled, OrcaSlicer emits a `warning`-level log line at startup
|
||||
to make the active input-injection surface obvious in logs, for example:
|
||||
|
||||
```
|
||||
UI automation server ENABLED ... input injection is active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Transport
|
||||
|
||||
The server speaks **HTTP/1.1** over the loopback TCP socket:
|
||||
|
||||
| Request | Response |
|
||||
|---|---|
|
||||
| `POST /jsonrpc` with a JSON-RPC 2.0 request body | A JSON-RPC 2.0 response with `Content-Type: application/json`. |
|
||||
| `GET /` | A plain-text health page: `OrcaSlicer automation server v1.0.0` (`Content-Type: text/plain`). |
|
||||
| Anything else | HTTP `404 Not Found`. |
|
||||
|
||||
The server is **single-client / serialized** in v1: it handles one request at a
|
||||
time on its own dedicated I/O thread. Connections are not kept alive; each request
|
||||
is answered and the socket is closed. Clients should issue requests sequentially.
|
||||
|
||||
---
|
||||
|
||||
## 3. JSON-RPC envelope
|
||||
|
||||
The protocol follows **JSON-RPC 2.0**.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{ "jsonrpc": "2.0", "id": <id>, "method": "<method>", "params": { ... } }
|
||||
```
|
||||
|
||||
- `params` may be omitted; the server treats a missing `params` as an empty object.
|
||||
|
||||
**Success response:**
|
||||
|
||||
```json
|
||||
{ "jsonrpc": "2.0", "id": <id>, "result": { ... } }
|
||||
```
|
||||
|
||||
**Error response:**
|
||||
|
||||
```json
|
||||
{ "jsonrpc": "2.0", "id": <id>, "error": { "code": <int>, "message": "<string>" } }
|
||||
```
|
||||
|
||||
The request `id` is echoed back in the response. When the request has no `id`, or
|
||||
when the request body cannot be parsed as JSON, the response `id` is `null`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Methods
|
||||
|
||||
There are 12 methods. Capabilities advertised by `automation.version` list the 11
|
||||
callable feature methods (every method except `automation.version` itself).
|
||||
|
||||
### `automation.version`
|
||||
|
||||
Returns server identity and the list of supported methods. Takes no parameters.
|
||||
|
||||
**Result:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"protocol": "2.0",
|
||||
"capabilities": [
|
||||
"tree.dump", "tree.find", "widget.get", "input.click", "input.type",
|
||||
"input.key", "sync.wait_for", "app.state", "screenshot.window", "file.open",
|
||||
"view.select"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `tree.dump`
|
||||
|
||||
Snapshot the live UI tree as a single root node with nested children.
|
||||
|
||||
**Params (all optional):**
|
||||
|
||||
| Param | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `root` | string (id or path) | full tree | Root the dump at the node with this id/path. |
|
||||
| `max_depth` | int | `-1` | Maximum depth to descend. `-1` = unlimited. |
|
||||
| `visible_only` | bool | `false` | When true, omit non-visible nodes. |
|
||||
| `include_imgui` | bool | `true` | When true, include ImGui items. |
|
||||
|
||||
**Result:** the serialized root [node](#5-unified-node-shape), with `children`
|
||||
included.
|
||||
|
||||
### `tree.find`
|
||||
|
||||
Find all nodes matching a [target predicate](#6-target--locator).
|
||||
|
||||
**Params:** a target predicate — any combination of `name`, `class`, `label`,
|
||||
`value`, `backend` (provided fields are ANDed). The params object is the target
|
||||
itself (it is *not* wrapped in a `target` key for this method).
|
||||
|
||||
**Result:** a **flat JSON array** of matching nodes. The nodes in this array are
|
||||
returned **without** their `children` (use `widget.get`/`tree.dump` to descend).
|
||||
|
||||
### `widget.get`
|
||||
|
||||
Fetch a single node by [target](#6-target--locator).
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `target` | object | yes | Target spec (id / path / predicate). |
|
||||
|
||||
**Result:** a single [node](#5-unified-node-shape), with its `children` included.
|
||||
|
||||
**Errors:** `1001` if the target is **not found** *or* **ambiguous** (more than one
|
||||
match).
|
||||
|
||||
### `input.click`
|
||||
|
||||
Click a resolved, actionable node.
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `target` | object | required | Target spec; must resolve to exactly one node. |
|
||||
| `button` | string | `"left"` | `"left"`, `"right"`, or `"middle"`. |
|
||||
| `double` | bool | `false` | Double-click when true. |
|
||||
| `modifiers` | array of string | `[]` | Held modifiers: any of `"ctrl"`, `"shift"`, `"alt"`, `"cmd"` (`"meta"` is accepted as an alias of `"cmd"`). |
|
||||
|
||||
**Result:** `{ "ok": true }`.
|
||||
|
||||
**Errors:** `1001` not found / ambiguous; `1002` if the target is disabled or
|
||||
hidden (not actionable). The click path raises and focuses the target's top-level
|
||||
window before injecting the click.
|
||||
|
||||
### `input.type`
|
||||
|
||||
Type text into the currently focused control.
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `text` | string | yes | The text to type. |
|
||||
| `target` | object | no | If given, this node is clicked first (to focus it) before typing. |
|
||||
|
||||
**Result:** `{ "ok": true }`.
|
||||
|
||||
**Errors:** if `target` is supplied, the same actionability errors as
|
||||
`input.click` apply (`1001` / `1002`).
|
||||
|
||||
### `input.key`
|
||||
|
||||
Send a key chord (a key plus optional modifiers) to the focused window.
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `keys` | string or array | yes | Either a `"+"`-joined string like `"ctrl+s"`, or an array like `["ctrl", "s"]`. The last token is the key; earlier tokens are modifiers. |
|
||||
|
||||
**Result:** `{ "ok": true }`.
|
||||
|
||||
**Key names must be lowercase.** Recognized key names include `"enter"`, `"tab"`,
|
||||
`"esc"`, `"space"`, `"delete"`, `"backspace"`, `"f5"` (and other function keys),
|
||||
and single characters (e.g. `"s"`, `"a"`). Recognized modifiers are `"ctrl"`,
|
||||
`"shift"`, `"alt"`, `"cmd"` (with `"meta"` as an alias for `"cmd"`).
|
||||
**Unrecognized or uppercase key names are silently ignored** — no error is
|
||||
returned, the key simply does not fire. Use lowercase names exclusively.
|
||||
|
||||
### `sync.wait_for`
|
||||
|
||||
Poll the UI until a target node reaches a desired state, or time out. This is the
|
||||
preferred way to synchronize with asynchronous UI changes (it replaces fragile
|
||||
fixed sleeps). Internally it repeatedly refreshes and dumps the tree, re-resolves
|
||||
the target, and evaluates the requested state until it is satisfied.
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `target` | object | required | Target spec. |
|
||||
| `state` | string | required | One of `"exists"`, `"visible"`, `"enabled"`, `"value"`. |
|
||||
| `value` | string | — | Required when `state` is `"value"`; the expected value to match. |
|
||||
| `timeout_ms` | int | `5000` | Maximum time to wait, in milliseconds. |
|
||||
| `poll_ms` | int | `100` | Poll interval, in milliseconds (minimum 1). |
|
||||
|
||||
State semantics:
|
||||
|
||||
- `exists` — the target resolves to a node.
|
||||
- `visible` — the node exists and is visible.
|
||||
- `enabled` — the node exists and is **both enabled and visible**.
|
||||
- `value` — the node has a value and that value equals the supplied `value`.
|
||||
|
||||
**Result:** `{ "ok": true, "elapsed_ms": <int> }`.
|
||||
|
||||
**Errors:** `1003` on timeout (the state was not reached within `timeout_ms`).
|
||||
|
||||
### `app.state`
|
||||
|
||||
Return a high-level application-state snapshot. Takes no parameters.
|
||||
|
||||
**Result:**
|
||||
|
||||
```json
|
||||
{
|
||||
"active_tab": "<string>",
|
||||
"project_loaded": <bool>,
|
||||
"slicing": <bool>,
|
||||
"slice_progress": <int>,
|
||||
"foreground": <bool>,
|
||||
"modal_dialog": "<string>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `active_tab` | The active top-level tab/page. |
|
||||
| `project_loaded` | Whether a project/model is currently loaded. |
|
||||
| `slicing` | Whether slicing is currently in progress. |
|
||||
| `slice_progress` | Slicing progress (`-1` when unknown). |
|
||||
| `foreground` | Whether the main window is in the foreground. |
|
||||
| `modal_dialog` | Present only when a modal dialog is active; identifies it. Omitted otherwise. |
|
||||
|
||||
### `screenshot.window`
|
||||
|
||||
Capture a window as a PNG, exactly as it appears on screen.
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `target` | object | main frame | If given, capture this window; otherwise capture the main frame. |
|
||||
|
||||
**Result:** `{ "png_base64": "<base64 PNG>", "width": <int>, "height": <int> }`.
|
||||
|
||||
**Errors:** `1005` on screenshot failure; `1001` if a supplied `target` is not
|
||||
found or ambiguous.
|
||||
|
||||
**How it works:** the window's on-screen rectangle is read back from the
|
||||
DWM-composited desktop framebuffer (`wxScreenDC`), so the capture includes every
|
||||
native child control, the OpenGL 3D viewport, and ImGui overlays — it is a faithful
|
||||
image of what the user sees. (Capturing the parent window's own client DC instead
|
||||
would clip out child HWNDs and the GL surface, leaving them black; that is why this
|
||||
method reads from the screen.)
|
||||
|
||||
**Caveats:**
|
||||
|
||||
- The window must be **visible and unobscured**. Because the source is the on-screen
|
||||
framebuffer, any overlapping window occludes the captured region. The backend
|
||||
raises the target window before capturing.
|
||||
- **HiDPI:** the reported `width`/`height` come from the window's logical client size,
|
||||
while the screen framebuffer is in physical pixels. On per-monitor-DPI displays the
|
||||
two can differ; the capture may be cropped or scaled relative to the logical size.
|
||||
- Because the capture is the live on-screen image, the 3D content reflects the
|
||||
**current view**: the model in the 3D editor, or the gcode toolpaths in Preview
|
||||
after a slice. There is no separate offscreen 3D-render method — the window
|
||||
capture already includes whatever the GL canvas is showing.
|
||||
|
||||
### `file.open`
|
||||
|
||||
Load one or more files into the **already-running** instance at runtime, by calling
|
||||
`Plater::load_files(...)` directly on the GUI thread. This is the supported way to add
|
||||
or swap a model without relaunching the process. Loading is **synchronous**: when the
|
||||
call returns `ok: true`, `app.state().project_loaded` is already `true` (no polling
|
||||
race).
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `paths` | string or array of strings | yes | One or more **absolute** file paths. A bare string is accepted and treated as a one-element list. Paths are read from the **host (server) filesystem** — client and server are localhost-only. |
|
||||
|
||||
`.3mf` files are routed as projects and meshes as models automatically, based on file
|
||||
content (the same default strategy as drag-drop); there is no `as_project` flag in v1.
|
||||
|
||||
**Result:** `{ "ok": true, "loaded": <int> }`, where `loaded` is the number of objects
|
||||
added to the scene (`load_files(...).size()`).
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `-32602` (invalid params) — `paths` is missing, is not a string/array, contains a
|
||||
non-string entry, or yields no non-empty path.
|
||||
- `1007` (load failed) — `load_files` returned empty or threw (file not found, parse
|
||||
error, or unsupported format).
|
||||
- `1004` (GUI busy) — the GUI-thread marshal timed out. An extremely large model can
|
||||
exceed the marshal timeout and surface here; documented, not mitigated in v1.
|
||||
|
||||
### `view.select`
|
||||
|
||||
Switch the main window to a top-level view/tab at runtime. Useful to put the UI in a
|
||||
known state before other actions — e.g. switch to **Prepare** before loading a model,
|
||||
or to **Preview** after slicing.
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `view` | string | yes | The target view. One of: `home`, `prepare` (3D editor), `preview` (sliced G-code), `device`, `multi_device`, `project`, `calibration`. |
|
||||
|
||||
**Result:** `{ "ok": true, "view": <string>, "index": <int> }`, where `index` is the
|
||||
resulting tab index (it can vary with layout, since some tabs — e.g. `multi_device` —
|
||||
are only present in certain configurations).
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `-32602` (invalid params) — `view` is missing, is not a string, or is empty.
|
||||
- `1001` (not found) — the view name is unknown, or that view is not available in the
|
||||
current layout (for example `prepare`/`preview` in G-code-viewer mode, or
|
||||
`multi_device` when multi-device is disabled).
|
||||
- `1004` (GUI busy) — the GUI-thread marshal timed out.
|
||||
|
||||
---
|
||||
|
||||
## 5. Unified node shape
|
||||
|
||||
Both wx widgets and ImGui items are reported with the same node schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"backend": "wx" | "imgui",
|
||||
"id": "<string>",
|
||||
"path": "<string>",
|
||||
"class": "<string>",
|
||||
"label": "<string>",
|
||||
"rect": { "x": <int>, "y": <int>, "w": <int>, "h": <int> },
|
||||
"enabled": <bool>,
|
||||
"visible": <bool>,
|
||||
"value": "<string>",
|
||||
"children": [ <node>, ... ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `backend` | `"wx"` for native wxWidgets controls, `"imgui"` for immediate-mode ImGui items. |
|
||||
| `id` | The automation id when one is set, otherwise a derived id. For ImGui items the `path` doubles as the `id`. |
|
||||
| `path` | Positional path, e.g. `"MainFrame/Panel[2]/Button[0]"`. For ImGui items: `"ImGui/<window>/<label>"`. |
|
||||
| `class` | wx class name, or the ImGui item type. |
|
||||
| `label` | The control's label/caption. May include an ImGui `##`-id suffix for ImGui items. |
|
||||
| `rect` | Bounding rectangle in **screen coordinates**. |
|
||||
| `enabled` | Whether the control is enabled. |
|
||||
| `visible` | Whether the control is visible. |
|
||||
| `value` | The control's value (text/choice/check/slider, etc.). **Omitted entirely** when the control has no applicable value. |
|
||||
| `children` | Child nodes. **wx only**, and present only when children are included (e.g. `tree.dump`, `widget.get`). ImGui items are flat (no children) and are listed under their window. |
|
||||
|
||||
Notes:
|
||||
|
||||
- The `value` key is **omitted** (not `null`) when the control has no value.
|
||||
- `children` is present only for wx nodes when children are requested; ImGui nodes
|
||||
never carry `children`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Target / locator
|
||||
|
||||
Most methods accept a **target** object that identifies one or more nodes. A
|
||||
target may specify:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | Exact automation id. |
|
||||
| `path` | Exact positional path. |
|
||||
| `name` | Predicate: matches either the node's `id` **or** its `label`. |
|
||||
| `class` | Predicate: exact class name. |
|
||||
| `label` | Predicate: exact label. |
|
||||
| `value` | Predicate: node has a value and it equals this string. |
|
||||
| `backend` | Predicate: `"wx"` or `"imgui"`. |
|
||||
|
||||
**Resolution order:** **`id` → `path` → predicate.**
|
||||
|
||||
- If `id` is present, only `id` is used (exact match).
|
||||
- Else if `path` is present, only `path` is used (exact match).
|
||||
- Else the predicate fields (`name`, `class`, `label`, `value`, `backend`) are
|
||||
used, and all provided predicate fields are **ANDed** together.
|
||||
|
||||
Action methods (`input.click`, `input.type` with a target, `widget.get`, and
|
||||
single-target `screenshot.window`) require a **unique** match. If the target
|
||||
resolves to zero matches or more than one match, the call fails with error `1001`
|
||||
(not found / ambiguous). `tree.find` is the exception: it returns *all* matches as
|
||||
an array and never errors on ambiguity.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error codes
|
||||
|
||||
Standard JSON-RPC codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `-32700` | Parse error — the request body was not valid JSON. |
|
||||
| `-32600` | Invalid request — missing/invalid `method`. |
|
||||
| `-32601` | Method not found — unknown method name. |
|
||||
| `-32602` | Invalid params — missing/invalid parameters for the method. |
|
||||
|
||||
Application-specific codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `1001` | Widget/target not found **or** ambiguous (more than one match). |
|
||||
| `1002` | Not actionable — the target is disabled or hidden. |
|
||||
| `1003` | Wait timeout — `sync.wait_for` did not reach the requested state in time. |
|
||||
| `1004` | GUI thread busy / timeout — a backend call could not be marshaled onto the GUI thread in time (wedged GUI). |
|
||||
| `1005` | Screenshot failed. |
|
||||
| `1006` | Disabled. |
|
||||
| `1007` | Load failed — `file.open`'s `load_files` returned empty or threw (not found, parse error, unsupported format). |
|
||||
|
||||
---
|
||||
|
||||
## 8. Automation-id naming conventions & instrumented ids
|
||||
|
||||
Stable automation ids follow these prefix conventions:
|
||||
|
||||
| Prefix | Used for |
|
||||
|---|---|
|
||||
| `btn_` | Buttons |
|
||||
| `combo_` | Preset combo boxes |
|
||||
| `tab_` | Tabs |
|
||||
| `canvas_` | Canvases |
|
||||
| `dlg_` | Dialog buttons |
|
||||
|
||||
### Instrumented ids (as-built in v1)
|
||||
|
||||
The following controls currently carry stable automation ids:
|
||||
|
||||
| id | Control | Note |
|
||||
|---|---|---|
|
||||
| `btn_slice` | Slice-plate button | |
|
||||
| `btn_export` | Print / Export button | Multi-purpose: the action (Print plate / Export G-code / Send) depends on the current mode. |
|
||||
| `tab_device` | Device / Monitor tab (`MonitorPanel`) | |
|
||||
| `combo_printer` | Printer preset combo (sidebar) | |
|
||||
| `combo_filament` | Filament preset combo (sidebar) | First filament row only; extra multi-material rows are not instrumented. |
|
||||
| `canvas_3d` | 3D editor GL canvas | |
|
||||
|
||||
### Controls NOT instrumented in v1
|
||||
|
||||
Several controls are intentionally **not** instrumented in v1 because they have no
|
||||
stable `wxWindow` target to attach an id to:
|
||||
|
||||
- **`combo_process`** — process settings are not a sidebar combo box in the current
|
||||
OrcaSlicer layout, so there is no combo control to instrument.
|
||||
- **`btn_add`** — the add/import-object control is a `GLToolbar` item rendered
|
||||
*inside* the GL canvas, not a `wxWindow`.
|
||||
- **`tab_prepare` / `tab_preview`** — the Prepare and Preview notebook pages are
|
||||
both backed by the **same** window, and the per-tab buttons are private; there is
|
||||
no distinct stable window to target.
|
||||
|
||||
For controls that are not instrumented, scripts should fall back to class / label /
|
||||
path lookup (for wx controls) or ImGui-item lookup (for ImGui controls).
|
||||
|
||||
---
|
||||
|
||||
## 9. ImGui notes
|
||||
|
||||
ImGui is **immediate-mode**: an item is addressable only while it is being drawn in
|
||||
the current frame. The automation backend records ImGui items each frame, and a
|
||||
`refresh_ui` is forced before every read or action so that the latest frame's items
|
||||
are captured.
|
||||
|
||||
Consequences and conventions:
|
||||
|
||||
- Use [`sync.wait_for`](#syncwait_for) to wait for a transient gizmo or panel item
|
||||
to appear before acting on it.
|
||||
- ImGui items are reported with `backend: "imgui"`, a `path` of the form
|
||||
`ImGui/<window>/<label>`, and that **path doubles as the item's `id`** in v1.
|
||||
- ImGui items are **flat** — they have no `children` and are listed under their
|
||||
window.
|
||||
- Labels may include ImGui `##`-id suffixes (the part after `##` that ImGui uses to
|
||||
disambiguate identically labeled widgets).
|
||||
- Raw `ImGui::` gizmos that are *not* routed through the instrumented
|
||||
`ImGuiWrapper` widgets (for example some Emboss / SVG / Text gizmo controls) are
|
||||
only covered at the **window level** in v1; their individual sub-items are not
|
||||
enumerated.
|
||||
|
||||
---
|
||||
|
||||
## 10. Platform & display caveats
|
||||
|
||||
- **Input requires a focused, visible window.** OS-level input injection uses
|
||||
`wxUIActionSimulator`, which requires a focused, visible window. The click path
|
||||
raises and focuses the target's top-level window first.
|
||||
- **Linux CI needs a display.** There must be an X display available; wrap test
|
||||
runs with `xvfb-run` (for example, `xvfb-run -a python example_slice.py ...`).
|
||||
- **Input is asynchronous.** Do **not** rely on fixed sleeps. Use
|
||||
[`sync.wait_for`](#syncwait_for) — for example, wait for `btn_export` to become
|
||||
`enabled` after slicing completes — rather than sleeping for a guessed duration.
|
||||
- **`screenshot.window` reads the screen.** It captures the on-screen, DWM-composited
|
||||
framebuffer, so the target window must be visible and unobscured, and the result is
|
||||
in physical pixels (see HiDPI caveat under [`screenshot.window`](#screenshotwindow)).
|
||||
The capture includes the GL 3D viewport as currently shown (model or toolpaths).
|
||||
- **Single-client / serialized.** v1 handles one request at a time; issue requests
|
||||
sequentially from a single client.
|
||||
- **GUI-thread marshaling.** Every backend call is marshaled onto the GUI thread
|
||||
with a timeout. A wedged or unresponsive GUI returns error `1004`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick start
|
||||
|
||||
Using the reference client in `tools/automation/orca_automation.py`:
|
||||
|
||||
```python
|
||||
from orca_automation import OrcaClient
|
||||
|
||||
orca = OrcaClient(port=13619)
|
||||
print(orca.version()) # {'version': '1.0.0', ...}
|
||||
|
||||
orca.select_view("prepare") # switch to the 3D editor
|
||||
orca.open(r"C:\models\part.stl") # load a model at runtime (synchronous)
|
||||
|
||||
orca.click({"id": "btn_slice"}) # start slicing the plate
|
||||
orca.wait_for({"id": "btn_export"}, # wait until slicing finishes
|
||||
state="enabled", timeout_ms=180000)
|
||||
|
||||
orca.select_view("preview") # switch to the sliced G-code preview
|
||||
png = orca.screenshot() # on-screen capture (incl. 3D view)
|
||||
with open("window.png", "wb") as f:
|
||||
f.write(png)
|
||||
```
|
||||
|
||||
For a full, runnable end-to-end example — launching OrcaSlicer with the automation
|
||||
flags, loading a model, slicing, waiting for completion, and saving a window PNG —
|
||||
see `tools/automation/example_slice.py`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Future work
|
||||
|
||||
Planned enhancements beyond v1:
|
||||
|
||||
- **Authentication token** plus a Preferences toggle to enable/disable the server
|
||||
from the GUI.
|
||||
- **WebSocket push events** for real-time UI/state notifications (instead of
|
||||
polling).
|
||||
- **Per-item ImGui gizmo instrumentation** so individual gizmo sub-controls (Emboss
|
||||
/ SVG / Text, etc.) are addressable, not just at the window level.
|
||||
- **More widget ids** — the process combo, the add/import button, and the
|
||||
Prepare/Preview tabs once they expose stable windows.
|
||||
- An **MCP wrapper** to expose the automation surface to model-context tooling.
|
||||
|
||||
---
|
||||
|
||||
## Verification (v1)
|
||||
|
||||
This section records the final regression gate for the v1 feature: confirmation
|
||||
that the protocol core is covered by unit tests, that the existing test suites
|
||||
are unaffected, and that the **disabled path (automation OFF, the default) is a
|
||||
true no-op** — zero new threads, zero socket binds, zero allocations, and zero
|
||||
behavior change.
|
||||
|
||||
### Unit-suite results (Release, Windows / MSVC, Ninja Multi-Config)
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| `automation` (protocol core) | **32 / 32 passed** |
|
||||
| `libslic3r` (most affected by the additive `PrintConfig.cpp` CLI options) | **99 / 99 passed** |
|
||||
| `fff_print` | **14 / 14 passed** |
|
||||
| `libnest2d` | **14 / 14 passed** |
|
||||
| `sla_print` | **21 / 21 passed** |
|
||||
| `slic3rutils` | 3 / 5 passed — 2 pre-existing `[OrcaCloudServiceAgent]` SEGFAULTs, **unrelated to automation** (see note) |
|
||||
|
||||
> The two `slic3rutils` failures are `Orca cloud flat/nested session resolves
|
||||
> display name consistently`. They exercise `Slic3r::OrcaCloudServiceAgent`, which
|
||||
> the automation branch does **not** touch (verified via `git diff --stat
|
||||
> main...HEAD` — no change to `src/slic3r/Utils/OrcaCloudServiceAgent.*` or
|
||||
> `tests/slic3rutils/*`). They are pre-existing and not a regression introduced by
|
||||
> this feature.
|
||||
|
||||
### Static disabled-path audit (the core regression guarantee)
|
||||
|
||||
Verified by code reading that with no `--automation-server` flag:
|
||||
|
||||
- **Flag defaults off.** `m_automation_port` defaults to `0`
|
||||
(`src/slic3r/GUI/GUI_App.hpp:249`); `is_automation_enabled()` returns
|
||||
`m_automation_port > 0` (`GUI_App.hpp:386`) → `false` by default.
|
||||
- **No server / thread / socket.** `post_init()` calls
|
||||
`start_automation_server()` **only** when
|
||||
`init_params->automation_port > 0` (`src/slic3r/GUI/GUI_App.cpp:737-740`), and
|
||||
`start_automation_server()` itself early-returns when `m_automation_port <= 0`
|
||||
(`GUI_App.cpp:7097`). The backend / dispatcher / beast server objects are
|
||||
constructed nowhere else → no `orca_automation` thread and no localhost bind
|
||||
when the flag is absent.
|
||||
- **Recording hooks short-circuit.** `ImGuiWrapper::automation_record_last_item`
|
||||
has as its **first statement** `if (!wxGetApp().is_automation_enabled())
|
||||
return;` (`src/slic3r/GUI/ImGuiWrapper.cpp:576-577`) — a single bool check, no
|
||||
`ImGuiItemRecord` allocation and no `ImGuiItemTable` access on the disabled
|
||||
path. In `ImGuiWrapper::render()` the window-enumeration loop and
|
||||
`swap_frame()` are fully wrapped in `if (wxGetApp().is_automation_enabled())`
|
||||
(`ImGuiWrapper.cpp:599-611`); when off, `render()` is its original
|
||||
`ImGui::Render()` + `render_draw_data()` plus one bool check.
|
||||
- **Instrumentation is inert.** The ~7 `set_automation_id(...)` calls
|
||||
(`MainFrame.cpp:1330,1389,1841,1842`; `Plater.cpp:1772,2172,5068`) only store a
|
||||
pointer into a static registry and bind a `wxEVT_DESTROY` pruning handler
|
||||
(`src/slic3r/GUI/Automation/AutomationRegistry.cpp:24-36`). The registry is
|
||||
**read** only via `window_for_automation_id` / `automation_id_of`, which are
|
||||
called solely by the backend while the server is running → harmless when off.
|
||||
- **CLI options are purely additive.** `automation_server` (coBool, default
|
||||
`false`) and `automation_server_port` (coInt, default `13619`) are new `add()`
|
||||
entries appended after `enable_timelapse`
|
||||
(`src/libslic3r/PrintConfig.cpp:10794-10805`); no existing option is changed.
|
||||
`GUI_InitParams::automation_port` defaults to `0`
|
||||
(`src/slic3r/GUI/GUI_Init.hpp:37`) and is set only when `--automation-server`
|
||||
is supplied (`src/OrcaSlicer.cpp:1345-1348`).
|
||||
|
||||
**Conclusion:** with automation OFF (the default), the feature allocates nothing
|
||||
and changes nothing — the only added cost on any hot path is a single boolean
|
||||
comparison.
|
||||
|
||||
### Deferred manual runtime checks (require a display / Xvfb)
|
||||
|
||||
These need a live GUI and cannot be run headlessly in CI; they are the manual
|
||||
acceptance steps:
|
||||
|
||||
1. Launch **without** `--automation-server` → `curl http://127.0.0.1:13619/`
|
||||
fails to connect (no listener); no `orca_automation` thread exists.
|
||||
2. Launch **with** `--automation-server --automation-server-port=13619` →
|
||||
`GET /` returns the health text; `POST /jsonrpc {"method":"automation.version"}`
|
||||
returns version / protocol / capabilities; `widget.get {"target":{"id":"btn_slice"}}`
|
||||
returns a node with a sensible screen rect.
|
||||
3. Interactive sanity: open a gizmo / move sliders with automation OFF → no
|
||||
visual or behavior change.
|
||||
|
||||
See `tools/automation/example_slice.py` for the runnable end-to-end path.
|
||||
608
docs/superpowers/plans/2026-06-03-orcaslicer-file-open-method.md
Normal file
608
docs/superpowers/plans/2026-06-03-orcaslicer-file-open-method.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# `file.open` Automation Method Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `file.open` JSON-RPC automation method that loads one or more files into an already-running OrcaSlicer instance by calling `Plater::load_files(...)` synchronously on the GUI thread.
|
||||
|
||||
**Architecture:** Follows the existing `screenshot.window` / `app.state` method pattern. A new pure-virtual `open_files(paths)` is added to the wx-free `IUiBackend` interface; `WxUiBackend` implements it via the existing `run_on_gui(...)` GUI-thread marshal calling `Plater::load_files`; the `JsonRpcDispatcher` gains a `file.open` route, a param-parsing helper, and a new `kErrLoadFailed = 1007` error code. The unit-testable surface (dispatcher + param validation + routing) is driven against `MockUiBackend`.
|
||||
|
||||
**Tech Stack:** C++17, nlohmann::json, Catch2 v2 (`catch_all.hpp` / `Catch2WithMain`), wxWidgets, CMake + Ninja Multi-Config. Python 3 reference client (stdlib only).
|
||||
|
||||
---
|
||||
|
||||
## Design-spec note (resolve before coding)
|
||||
|
||||
The design spec's error table reads `1002 | kInvalidParams | paths missing/empty…`, but in the codebase `kInvalidParams` is the standard JSON-RPC code **`-32602`**, while `1002` is `kErrNotActionable`. The spec's **Constant column (`kInvalidParams`) is authoritative** and matches every other param-validation path in the dispatcher (e.g. `m_input_type` throws `kInvalidParams` for a bad `text`). This plan therefore validates `file.open` params with **`kInvalidParams` (-32602)**, exactly like the existing handlers, and the tests assert `== kInvalidParams`. The literal "1002" in the spec is a typo; do not emit code 1002 for param errors.
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/slic3r/GUI/Automation/IUiBackend.hpp` | modify | Add pure-virtual `int open_files(paths)` to the backend abstraction (stays wx-free). |
|
||||
| `src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp` | modify | Add `kErrLoadFailed = 1007` constant + `m_file_open` declaration. |
|
||||
| `src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp` | modify | Add `parse_paths` helper, `m_file_open` body, dispatch route, capabilities entry. |
|
||||
| `src/slic3r/GUI/Automation/WxUiBackend.hpp` | modify | Declare `open_files` override. |
|
||||
| `src/slic3r/GUI/Automation/WxUiBackend.cpp` | modify | Implement `open_files` via `run_on_gui` → `Plater::load_files`. |
|
||||
| `tests/automation/MockUiBackend.hpp` | modify | `open_files` override: record paths + return-count + fail knob. |
|
||||
| `tests/automation/test_dispatcher.cpp` | modify | Catch2 tests for routing, string/array, validation, failure, capabilities. |
|
||||
| `tools/automation/orca_automation.py` | modify | `open(self, paths)` client wrapper. |
|
||||
| `tools/automation/example_slice.py` | modify | Launch without a model arg, then `orca.open([model])`. |
|
||||
| `doc/automation.md` | modify | Document the method, capabilities, error `1007`. |
|
||||
|
||||
**Build/test layout:** Ninja Multi-Config in `build/`. The unit suite target is `automation_tests`; its sources (`tests/automation/CMakeLists.txt`) compile `JsonRpcDispatcher.cpp` + `MockUiBackend` but **not** `WxUiBackend.cpp`. So dispatcher/mock changes are fully unit-testable headlessly; `WxUiBackend.cpp` is verified by the full app build only.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend the backend abstraction (interface + mock + error code)
|
||||
|
||||
Adds the `open_files` contract so tests can be written. Adding a pure virtual to `IUiBackend` forces every implementation to provide it — in the unit-test target that is only `MockUiBackend`, so this task keeps the `automation_tests` build green.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/slic3r/GUI/Automation/IUiBackend.hpp`
|
||||
- Modify: `src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp:19`
|
||||
- Modify: `tests/automation/MockUiBackend.hpp`
|
||||
|
||||
- [ ] **Step 1: Add the error constant**
|
||||
|
||||
In `src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp`, after the existing `kErrDisabled` line (currently line 19), add:
|
||||
|
||||
```cpp
|
||||
constexpr int kErrDisabled = 1006;
|
||||
constexpr int kErrLoadFailed = 1007; // file.open: load_files returned empty / threw
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the pure-virtual `open_files` to the interface**
|
||||
|
||||
In `src/slic3r/GUI/Automation/IUiBackend.hpp`, inside `class IUiBackend`, immediately after the `screenshot_window` pure virtual (currently line 97), add:
|
||||
|
||||
```cpp
|
||||
// Load one or more files (absolute paths) into the running instance on the GUI
|
||||
// thread. Returns the number of objects added to the scene (load_files(...).size()).
|
||||
// Throws AutomationError(kErrLoadFailed) when nothing loads. Header stays wx-free:
|
||||
// the concrete LoadStrategy is chosen inside WxUiBackend, not exposed here.
|
||||
virtual int open_files(const std::vector<std::string>& paths) = 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `open_files` in the mock with record + knobs**
|
||||
|
||||
In `tests/automation/MockUiBackend.hpp`: add an include for the error constant near the top (after the `IUiBackend.hpp` include on line 2):
|
||||
|
||||
```cpp
|
||||
#include "slic3r/GUI/Automation/IUiBackend.hpp"
|
||||
#include "slic3r/GUI/Automation/JsonRpcDispatcher.hpp" // kErrLoadFailed
|
||||
```
|
||||
|
||||
Add recorded-call + canned-output members. After the `screenshot_window_count` recorded field (line 20) add:
|
||||
|
||||
```cpp
|
||||
int screenshot_window_count = 0;
|
||||
std::vector<std::vector<std::string>> opened_paths; // paths of each open_files()
|
||||
```
|
||||
|
||||
After the `click_result` canned field (line 26) add:
|
||||
|
||||
```cpp
|
||||
bool click_result = true;
|
||||
int open_return_count = 0; // value open_files() returns
|
||||
bool open_should_fail = false; // when true, open_files() throws kErrLoadFailed
|
||||
```
|
||||
|
||||
Add the override next to the other overrides, after `screenshot_window` (lines 49-51):
|
||||
|
||||
```cpp
|
||||
PngImage screenshot_window(const UiNode*) override {
|
||||
++screenshot_window_count; return canned_png;
|
||||
}
|
||||
int open_files(const std::vector<std::string>& paths) override {
|
||||
opened_paths.push_back(paths);
|
||||
if (open_should_fail)
|
||||
throw AutomationError(kErrLoadFailed, "mock load failed");
|
||||
return open_return_count;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build the unit-test target to confirm it still compiles**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests`
|
||||
Expected: build succeeds (the new pure virtual is satisfied by the mock; no behavior change yet).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/slic3r/GUI/Automation/IUiBackend.hpp src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp tests/automation/MockUiBackend.hpp
|
||||
git commit -m "feat(automation): add open_files to backend interface + kErrLoadFailed (1007)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `file.open` dispatcher handler (parse, route, validate, fail)
|
||||
|
||||
Implements the full JSON-RPC handler against the mock: param parsing (string or array), validation, routing to `open_files`, and `kErrLoadFailed` propagation.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp:49` (declaration)
|
||||
- Modify: `src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp`
|
||||
- Test: `tests/automation/test_dispatcher.cpp`
|
||||
|
||||
- [ ] **Step 1: Write the failing happy-path test (array of paths)**
|
||||
|
||||
Append to `tests/automation/test_dispatcher.cpp`:
|
||||
|
||||
```cpp
|
||||
TEST_CASE("file.open with an array of paths routes to backend", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.open_return_count = 3;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","file.open"},
|
||||
{"params",{{"paths", json::array({"C:/abs/a.stl","C:/abs/b.stl"})}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
CHECK(resp.at("result").at("loaded") == 3);
|
||||
REQUIRE(mock.opened_paths.size() == 1);
|
||||
REQUIRE(mock.opened_paths[0].size() == 2);
|
||||
CHECK(mock.opened_paths[0][0] == "C:/abs/a.stl");
|
||||
CHECK(mock.opened_paths[0][1] == "C:/abs/b.stl");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe "file.open with an array of paths routes to backend"`
|
||||
Expected: FAIL — `file.open` is an unknown method, so the response carries `error.code == -32601` and has no `result` (the `resp.at("result")` access throws). (If the exe path differs on your machine, locate it with `find build -iname automation_tests.exe`.)
|
||||
|
||||
- [ ] **Step 3: Declare the handler**
|
||||
|
||||
In `src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp`, after the `m_screenshot_window` declaration (currently line 49) add:
|
||||
|
||||
```cpp
|
||||
nlohmann::json m_screenshot_window(const nlohmann::json& params);
|
||||
nlohmann::json m_file_open(const nlohmann::json& params);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `parse_paths` helper and `m_file_open` body**
|
||||
|
||||
In `src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp`, add a `parse_paths` helper. Place it in the anonymous namespace that also holds `parse_keys` — insert it right before that namespace's closing `} // namespace` (currently line 130):
|
||||
|
||||
```cpp
|
||||
// "paths" may be a single string ("C:/a.stl") or an array of strings. Returns the
|
||||
// non-empty absolute paths; throws kInvalidParams when paths is missing, not a
|
||||
// string/array, contains a non-string entry, or yields no non-empty path.
|
||||
std::vector<std::string> parse_paths(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("paths"))
|
||||
throw AutomationError(kInvalidParams, "file.open requires 'paths'");
|
||||
const auto& p = params.at("paths");
|
||||
std::vector<std::string> out;
|
||||
if (p.is_string()) {
|
||||
out.push_back(p.get<std::string>());
|
||||
} else if (p.is_array()) {
|
||||
for (const auto& e : p) {
|
||||
if (!e.is_string())
|
||||
throw AutomationError(kInvalidParams, "'paths' entries must be strings");
|
||||
out.push_back(e.get<std::string>());
|
||||
}
|
||||
} else {
|
||||
throw AutomationError(kInvalidParams, "'paths' must be a string or array");
|
||||
}
|
||||
out.erase(std::remove_if(out.begin(), out.end(),
|
||||
[](const std::string& s) { return s.empty(); }),
|
||||
out.end());
|
||||
if (out.empty())
|
||||
throw AutomationError(kInvalidParams, "'paths' is empty");
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
(`<algorithm>` for `std::remove_if` is already included at the top of the file, line 4.)
|
||||
|
||||
Add the handler body next to the other handlers. After `m_screenshot_window` (currently ends line 343, just before the final `}}} // namespace`), add:
|
||||
|
||||
```cpp
|
||||
nlohmann::json JsonRpcDispatcher::m_file_open(const nlohmann::json& params) {
|
||||
const std::vector<std::string> paths = parse_paths(params);
|
||||
const int loaded = m_backend.open_files(paths);
|
||||
return { {"ok", true}, {"loaded", loaded} };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the dispatch route**
|
||||
|
||||
In `JsonRpcDispatcher::dispatch`, after the `screenshot.window` route (currently line 195) add:
|
||||
|
||||
```cpp
|
||||
if (method == "screenshot.window") return make_result(id, m_screenshot_window(params));
|
||||
if (method == "file.open") return make_result(id, m_file_open(params));
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the happy-path test to verify it passes**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe "file.open with an array of paths routes to backend"`
|
||||
Expected: PASS — 1 test case, all assertions passed.
|
||||
|
||||
- [ ] **Step 7: Add the remaining handler tests (string, validation, failure)**
|
||||
|
||||
Append to `tests/automation/test_dispatcher.cpp`:
|
||||
|
||||
```cpp
|
||||
TEST_CASE("file.open accepts a bare string path", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.open_return_count = 1;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","file.open"},
|
||||
{"params",{{"paths","C:/abs/a.stl"}}}});
|
||||
CHECK(resp.at("result").at("loaded") == 1);
|
||||
REQUIRE(mock.opened_paths.size() == 1);
|
||||
REQUIRE(mock.opened_paths[0].size() == 1);
|
||||
CHECK(mock.opened_paths[0][0] == "C:/abs/a.stl");
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with missing paths -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","file.open"},
|
||||
{"params", json::object()}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.opened_paths.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with empty paths array -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","file.open"},
|
||||
{"params",{{"paths", json::array()}}}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.opened_paths.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with a non-string entry -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","file.open"},
|
||||
{"params",{{"paths", json::array({"C:/a.stl", 42})}}}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.opened_paths.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("file.open backend load failure -> 1007", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.open_should_fail = true;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",6},{"method","file.open"},
|
||||
{"params",{{"paths","C:/abs/a.stl"}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrLoadFailed);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run all file.open tests to verify they pass**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe "file.open*"`
|
||||
Expected: PASS — 6 test cases, all assertions passed.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp tests/automation/test_dispatcher.cpp
|
||||
git commit -m "feat(automation): add file.open dispatcher handler with validation + tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Advertise `file.open` in `automation.version` capabilities
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp:166-172`
|
||||
- Test: `tests/automation/test_dispatcher.cpp`
|
||||
|
||||
- [ ] **Step 1: Write the failing capabilities test**
|
||||
|
||||
Append to `tests/automation/test_dispatcher.cpp`:
|
||||
|
||||
```cpp
|
||||
TEST_CASE("automation.version capabilities include file.open", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","automation.version"}});
|
||||
const auto& caps = resp.at("result").at("capabilities");
|
||||
bool found = false;
|
||||
for (const auto& c : caps) if (c == "file.open") found = true;
|
||||
CHECK(found);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe "automation.version capabilities include file.open"`
|
||||
Expected: FAIL — `CHECK(found)` is false; `file.open` is not yet in the capabilities array.
|
||||
|
||||
- [ ] **Step 3: Add `file.open` to the capabilities array**
|
||||
|
||||
In `JsonRpcDispatcher::m_version` (currently lines 166-172), add `"file.open"` to the array:
|
||||
|
||||
```cpp
|
||||
nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) {
|
||||
return { {"version", kAutomationVersion},
|
||||
{"protocol", "2.0"},
|
||||
{"capabilities", nlohmann::json::array({
|
||||
"tree.dump","tree.find","widget.get","input.click","input.type",
|
||||
"input.key","sync.wait_for","app.state","screenshot.window",
|
||||
"file.open" })} };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe "automation.version capabilities include file.open"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run the whole automation suite to confirm no regressions**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe --order rand --warn NoAssertions`
|
||||
Expected: PASS — all cases green (the pre-existing ~32 plus the 7 new `file.open`/capabilities cases ≈ 39).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp tests/automation/test_dispatcher.cpp
|
||||
git commit -m "feat(automation): advertise file.open in automation.version capabilities"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Implement `WxUiBackend::open_files` (real GUI-thread load)
|
||||
|
||||
Not covered by the headless unit suite (`WxUiBackend.cpp` is excluded from `automation_tests`); verified by the full app build + the manual runtime check in Task 8.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/slic3r/GUI/Automation/WxUiBackend.hpp:21`
|
||||
- Modify: `src/slic3r/GUI/Automation/WxUiBackend.cpp`
|
||||
|
||||
- [ ] **Step 1: Declare the override**
|
||||
|
||||
In `src/slic3r/GUI/Automation/WxUiBackend.hpp`, after the `screenshot_window` declaration (line 21) add:
|
||||
|
||||
```cpp
|
||||
PngImage screenshot_window(const UiNode* target) override;
|
||||
int open_files(const std::vector<std::string>& paths) override;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement `open_files`**
|
||||
|
||||
In `src/slic3r/GUI/Automation/WxUiBackend.cpp`, add the implementation just before the final `}}} // namespace Slic3r::GUI::Automation` (currently line 306):
|
||||
|
||||
```cpp
|
||||
int WxUiBackend::open_files(const std::vector<std::string>& paths) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> int {
|
||||
Plater* plater = wxGetApp().plater();
|
||||
if (plater == nullptr)
|
||||
throw AutomationError(kErrLoadFailed, "no plater to load into");
|
||||
// Default strategy matches drag-drop / Plater::load_files's own default: it
|
||||
// routes .3mf as a project and meshes as models based on file content, so no
|
||||
// as_project flag is needed in v1. ask_multi=false: never prompt.
|
||||
const LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig;
|
||||
std::vector<size_t> loaded;
|
||||
try {
|
||||
loaded = plater->load_files(paths, strategy, /*ask_multi=*/false);
|
||||
} catch (const std::exception& e) {
|
||||
throw AutomationError(kErrLoadFailed,
|
||||
std::string("load_files failed: ") + e.what());
|
||||
}
|
||||
if (loaded.empty())
|
||||
throw AutomationError(kErrLoadFailed, "load_files loaded nothing");
|
||||
return static_cast<int>(loaded.size());
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Notes for the implementer:
|
||||
- `LoadStrategy` and its `operator|` (namespace `Slic3r`, from `libslic3r/Format/bbs_3mf.hpp`) are already in scope: `WxUiBackend.cpp` includes `Plater.hpp` (line 7), which transitively pulls in the enum, and this translation unit lives in `Slic3r::GUI::Automation` so unqualified `LoadStrategy` resolves via the enclosing `Slic3r` namespace. No new include is required.
|
||||
- `Plater::load_files(const std::vector<std::string>&, LoadStrategy, bool)` is the existing string overload (`Plater.hpp:379`) — no `boost::filesystem::path` conversion needed.
|
||||
- `kErrLoadFailed` comes from `JsonRpcDispatcher.hpp`, already included at line 4.
|
||||
- An `AutomationError` thrown inside the `run_on_gui` lambda is captured by the helper's `set_exception` and rethrown from `fut.get()`, so the 1007 code propagates to the dispatcher unchanged.
|
||||
|
||||
- [ ] **Step 3: Build the full app to verify it compiles and links**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target OrcaSlicer`
|
||||
Expected: build succeeds (no missing-symbol / pure-virtual errors; `WxUiBackend` is now concrete).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/slic3r/GUI/Automation/WxUiBackend.hpp src/slic3r/GUI/Automation/WxUiBackend.cpp
|
||||
git commit -m "feat(automation): implement WxUiBackend::open_files via Plater::load_files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Python client wrapper `OrcaClient.open`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/automation/orca_automation.py:80-82`
|
||||
|
||||
- [ ] **Step 1: Add the `open` method**
|
||||
|
||||
In `tools/automation/orca_automation.py`, after the `key` method (ends line 82), add:
|
||||
|
||||
```python
|
||||
def key(self, keys) -> dict:
|
||||
# keys: "ctrl+s" or ["ctrl", "s"]
|
||||
return self._call("input.key", {"keys": keys})
|
||||
|
||||
def open(self, paths) -> dict:
|
||||
"""Load one or more files into the running instance at runtime.
|
||||
|
||||
`paths` is a single absolute path string or a list of them. Paths are read
|
||||
from the host filesystem by the server (localhost-only). Returns
|
||||
{"ok": True, "loaded": <count>}. Raises OrcaError 1007 on load failure."""
|
||||
if isinstance(paths, str):
|
||||
paths = [paths]
|
||||
return self._call("file.open", {"paths": list(paths)})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Smoke-test the wrapper's normalization offline (no server needed)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python -c "import sys; sys.path.insert(0, 'tools/automation'); import orca_automation as m; c = m.OrcaClient.__new__(m.OrcaClient); c._call = lambda meth, params=None: (meth, params); print(c.open('C:/a.stl')); print(c.open(['C:/a.stl','C:/b.stl']))"
|
||||
```
|
||||
Expected output:
|
||||
```
|
||||
('file.open', {'paths': ['C:/a.stl']})
|
||||
('file.open', {'paths': ['C:/a.stl', 'C:/b.stl']})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/automation/orca_automation.py
|
||||
git commit -m "feat(automation): add OrcaClient.open() wrapper for file.open"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Update `example_slice.py` to load at runtime via `file.open`
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/automation/example_slice.py:26-52`
|
||||
|
||||
- [ ] **Step 1: Launch without the model arg, then call `open`**
|
||||
|
||||
In `tools/automation/example_slice.py`, change the `subprocess.Popen` call (lines 26-31) to drop the trailing model positional:
|
||||
|
||||
```python
|
||||
proc = subprocess.Popen([
|
||||
args.orca,
|
||||
"--automation-server",
|
||||
f"--automation-server-port={args.port}",
|
||||
])
|
||||
```
|
||||
|
||||
Then replace the project-load wait block (currently lines 46-51) so the model is loaded at runtime via `file.open` instead of relying on a launch-time positional:
|
||||
|
||||
```python
|
||||
# Load the model into the already-running instance, then wait until the
|
||||
# project reports loaded. file.open is synchronous, so project_loaded is
|
||||
# already true on return; the wait is a belt-and-suspenders guard.
|
||||
orca.open([args.model])
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if orca.app_state().get("project_loaded"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Byte-compile the script to confirm no syntax errors**
|
||||
|
||||
Run: `python -m py_compile tools/automation/example_slice.py`
|
||||
Expected: no output, exit code 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/automation/example_slice.py
|
||||
git commit -m "docs(automation): example_slice.py loads model at runtime via file.open"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Document `file.open` in `doc/automation.md`
|
||||
|
||||
**Files:**
|
||||
- Modify: `doc/automation.md` (capabilities example §4 line 111-114; new method subsection after `screenshot.window`; error table §7)
|
||||
|
||||
- [ ] **Step 1: Add `file.open` to the capabilities example**
|
||||
|
||||
In `doc/automation.md`, update the `automation.version` result example (lines 111-114) to include `file.open`:
|
||||
|
||||
```json
|
||||
"capabilities": [
|
||||
"tree.dump", "tree.find", "widget.get", "input.click", "input.type",
|
||||
"input.key", "sync.wait_for", "app.state", "screenshot.window", "file.open"
|
||||
]
|
||||
```
|
||||
|
||||
The §4 prose count is already written for this: "There are 11 methods … the 10 callable feature methods" now matches exactly (10 capability entries + `automation.version` = 11). Leave that sentence unchanged.
|
||||
|
||||
- [ ] **Step 2: Add the `file.open` method subsection**
|
||||
|
||||
In `doc/automation.md`, immediately after the `screenshot.window` method subsection (it ends just before the `---` on line 303) and before that `---`, insert:
|
||||
|
||||
```markdown
|
||||
### `file.open`
|
||||
|
||||
Load one or more files into the **already-running** instance at runtime, by calling
|
||||
`Plater::load_files(...)` directly on the GUI thread. This is the supported way to add
|
||||
or swap a model without relaunching the process. Loading is **synchronous**: when the
|
||||
call returns `ok: true`, `app.state().project_loaded` is already `true` (no polling
|
||||
race).
|
||||
|
||||
**Params:**
|
||||
|
||||
| Param | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `paths` | string or array of strings | yes | One or more **absolute** file paths. A bare string is accepted and treated as a one-element list. Paths are read from the **host (server) filesystem** — client and server are localhost-only. |
|
||||
|
||||
`.3mf` files are routed as projects and meshes as models automatically, based on file
|
||||
content (the same default strategy as drag-drop); there is no `as_project` flag in v1.
|
||||
|
||||
**Result:** `{ "ok": true, "loaded": <int> }`, where `loaded` is the number of objects
|
||||
added to the scene (`load_files(...).size()`).
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `-32602` (invalid params) — `paths` is missing, is not a string/array, contains a
|
||||
non-string entry, or yields no non-empty path.
|
||||
- `1007` (load failed) — `load_files` returned empty or threw (file not found, parse
|
||||
error, or unsupported format).
|
||||
- `1004` (GUI busy) — the GUI-thread marshal timed out. An extremely large model can
|
||||
exceed the marshal timeout and surface here; documented, not mitigated in v1.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `1007` row to the error-code table**
|
||||
|
||||
In `doc/automation.md` §7, in the application-specific codes table, after the `1006` row (line 395) add:
|
||||
|
||||
```markdown
|
||||
| `1006` | Disabled. |
|
||||
| `1007` | Load failed — `file.open`'s `load_files` returned empty or threw (not found, parse error, unsupported format). |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add doc/automation.md
|
||||
git commit -m "docs(automation): document file.open method and error 1007"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Step 1: Full automation unit suite green**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target automation_tests && build/tests/automation/RelWithDebInfo/automation_tests.exe --order rand --warn NoAssertions`
|
||||
Expected: PASS — all cases (pre-existing ~32 + 7 new) green, no `NoAssertions` warnings on the new cases.
|
||||
|
||||
- [ ] **Step 2: Full app builds**
|
||||
|
||||
Run: `cmake --build build --config RelWithDebInfo --target ALL_BUILD -- -m`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Manual runtime check (requires a display)**
|
||||
|
||||
Launch with `--automation-server` and **no** model arg, then from a Python shell:
|
||||
```python
|
||||
from orca_automation import OrcaClient
|
||||
orca = OrcaClient(port=13619)
|
||||
print(orca.open(["C:/abs/path/cube.stl"])) # -> {'ok': True, 'loaded': 1}
|
||||
print(orca.app_state()["project_loaded"]) # -> True
|
||||
open("window.png","wb").write(orca.screenshot()) # PNG shows the loaded model
|
||||
```
|
||||
Expected: `loaded >= 1`, `project_loaded == True`, screenshot shows the model.
|
||||
|
||||
- [ ] **Step 4: Gating check (automation OFF is a no-op)**
|
||||
|
||||
Confirm by reading: with no `--automation-server` flag, the server/backend/dispatcher are never constructed (`GUI_App.cpp` `start_automation_server()` early-return), so `file.open` is unreachable. No new hot-path cost beyond the existing single bool check. (See `doc/automation.md` §Verification — disabled-path audit; this feature adds no new gating surface.)
|
||||
|
||||
---
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Additive only: one new method (`file.open`), one new error code (`1007`), one new capabilities entry, and one new backend interface method. No existing method, profile, project-file handling, or default behavior changes. The method is reachable only when `--automation-server` is passed.
|
||||
3401
docs/superpowers/plans/2026-06-03-orcaslicer-ui-automation.md
Normal file
3401
docs/superpowers/plans/2026-06-03-orcaslicer-ui-automation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
# Design — `file.open` automation method (runtime model loading)
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Branch:** `feature/automation`
|
||||
**Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Today a model can be loaded into OrcaSlicer **only at process launch**: the model path
|
||||
is passed as a CLI positional arg and OrcaSlicer's normal startup file-loading ingests
|
||||
it. The JSON-RPC automation protocol has **no** load method, so swapping or adding a
|
||||
model in an already-running instance requires a fresh process launch.
|
||||
|
||||
Driving the native File→Import dialog via `input.click` is not a viable substitute: the
|
||||
OS file picker is not a `wxWindow`, so it never appears in the `tree.dump` hierarchy
|
||||
(`WxUiBackend::dump_tree` walks `wxGetApp().mainframe` children only), and `input.click`
|
||||
can only target nodes resolved from that tree (no raw-coordinate click). Blind typing via
|
||||
`input.type` is mechanically possible but unobservable: the native picker is not a
|
||||
`wxDialog`, so `app.state().modal_dialog` and `sync.wait_for` cannot gate on it, leaving
|
||||
only sleep-and-hope timing. A direct API method is the clean fix.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a `file.open` JSON-RPC method that loads one or more files into a running instance by
|
||||
calling `Plater::load_files(...)` directly on the GUI thread. Out of scope: any
|
||||
dialog-driving mechanism (intercept hook or true OS-level drive) — explicitly deferred.
|
||||
|
||||
---
|
||||
|
||||
## Protocol
|
||||
|
||||
- **Method:** `file.open`
|
||||
- **Params:** `{ "paths": ["C:/abs/a.stl", ...] }`
|
||||
- A bare string is also accepted: `{ "paths": "C:/abs/a.stl" }`.
|
||||
- Paths must be **absolute**. The server reads them from the host filesystem
|
||||
(client/server are localhost-only).
|
||||
- **Result:** `{ "ok": true, "loaded": <count> }`
|
||||
- `count` is `load_files(...).size()` — the number of objects added to the scene.
|
||||
- **Errors:**
|
||||
| Code | Constant | Condition |
|
||||
|------|----------|-----------|
|
||||
| -32602 | `kInvalidParams` | `paths` missing/empty, a non-string entry, or no non-empty path |
|
||||
| 1004 | `kErrGuiBusy` | GUI-thread marshal timed out (`m_gui_timeout_ms`) |
|
||||
| 1007 | `kErrLoadFailed` | `load_files` returned empty / threw (not found, parse error, unsupported format) — **new code** |
|
||||
|
||||
## Semantics — synchronous
|
||||
|
||||
`Plater::load_files` runs and completes on the GUI thread. The backend marshals via the
|
||||
existing `run_on_gui(m_gui_timeout_ms, …)` helper and returns only after the load
|
||||
finishes. Consequently, when `file.open` returns `ok:true`, `app.state().project_loaded`
|
||||
is already `true` — there is no polling race.
|
||||
|
||||
Rejected alternative — async "fire-and-poll-`project_loaded`": adds client complexity and
|
||||
loses a definitive per-call error result, with no benefit since loading is synchronous.
|
||||
|
||||
**Caveat:** an extremely large model could exceed `m_gui_timeout_ms` and surface as
|
||||
`1004 kErrGuiBusy`. Documented; not mitigated in v1.
|
||||
|
||||
## Load strategy (v1 minimal)
|
||||
|
||||
Pass the default `LoadStrategy::LoadModel | LoadStrategy::LoadConfig` (identical to
|
||||
drag-drop / `Plater::load_files`'s default) with `ask_multi = false`. This already routes
|
||||
`.3mf` files as projects and meshes as models based on file content, so **no `as_project`
|
||||
flag is needed in v1**. A future `{ "as_project": bool }` flag remains possible but is not
|
||||
implemented now.
|
||||
|
||||
---
|
||||
|
||||
## Components / files to touch
|
||||
|
||||
Follows the existing `screenshot_window` / `app_state` method pattern.
|
||||
|
||||
1. **`src/slic3r/GUI/Automation/IUiBackend.hpp`** — add pure-virtual
|
||||
`int open_files(const std::vector<std::string>& paths)` returning the loaded count,
|
||||
throwing `AutomationError` on failure. Header stays wx-free (no `LoadStrategy` leak).
|
||||
2. **`src/slic3r/GUI/Automation/WxUiBackend.{hpp,cpp}`** — implement `open_files`:
|
||||
`run_on_gui(m_gui_timeout_ms, …)` → `wxGetApp().plater()->load_files(paths, default_strategy, false)`;
|
||||
throw `kErrLoadFailed` if the returned vector is empty.
|
||||
3. **`src/slic3r/GUI/Automation/JsonRpcDispatcher.{hpp,cpp}`** —
|
||||
- add `constexpr int kErrLoadFailed = 1007;`
|
||||
- declare + define `m_file_open(params)` (param parsing/validation; accept string or
|
||||
array; require ≥1 non-empty string path)
|
||||
- add dispatch route `if (method == "file.open") return make_result(id, m_file_open(params));`
|
||||
- add `"file.open"` to the capabilities array in `m_version`.
|
||||
4. **`tests/automation/MockUiBackend.hpp`** — `open_files` override recording the paths
|
||||
vector + a configurable return-count (and a throw/fail knob).
|
||||
5. **`tests/automation/test_dispatcher.cpp`** — Catch2 v2 tests:
|
||||
- array of paths → routes to backend, returns `loaded` count
|
||||
- bare-string path → normalized to one path
|
||||
- missing/empty `paths` → `-32602` (`kInvalidParams`)
|
||||
- backend load failure → `1007`
|
||||
- `automation.version` capabilities array includes `"file.open"`
|
||||
6. **`tools/automation/orca_automation.py`** — `open(self, paths)` wrapper (normalize
|
||||
`str` → `[str]`, send `file.open`).
|
||||
7. **`tools/automation/example_slice.py`** — launch **without** a model arg, then
|
||||
`orca.open([model])`, then wait for `project_loaded`.
|
||||
8. **`doc/automation.md`** — document method (params/result/errors), add to the
|
||||
capabilities list, method index, and error table (`1007`).
|
||||
|
||||
---
|
||||
|
||||
## Testing / verification
|
||||
|
||||
- **Build (Windows):** `cmake --build . --config RelWithDebInfo --target ALL_BUILD -- -m`.
|
||||
- **Unit:** `automation` Catch2 suite green including new tests (≈31 → ≈34 cases).
|
||||
- **Manual:** launch with `--automation-server` and **no** model arg → call `file.open`
|
||||
→ confirm `app.state().project_loaded` flips `true` and `screenshot.window` shows the
|
||||
model.
|
||||
- **Gating:** unchanged — the server only runs under `--automation-server`, so the method
|
||||
is a no-op (unreachable) when automation is disabled.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Additive only: a new method, a new error code, and a new capabilities entry. No change to
|
||||
existing methods, profiles, project-file handling, or default behavior.
|
||||
@@ -0,0 +1,314 @@
|
||||
# OrcaSlicer UI Automation — Design Spec
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Status:** Approved design, pending implementation plan
|
||||
**Topic:** Add an opt-in, externally-controllable UI automation interface to OrcaSlicer for automated GUI testing and future AI-agent control.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Add a localhost JSON-RPC server to a running OrcaSlicer GUI instance that lets an
|
||||
**external** script (or AI agent) drive and observe the real GUI. "Driving" is done
|
||||
the way a user would — simulated mouse/keyboard via `wxUIActionSimulator` — while
|
||||
"observing" reads the live widget state and captures screenshots.
|
||||
|
||||
The interface must cover **three UI technologies** present in OrcaSlicer:
|
||||
|
||||
1. Native **wxWidgets** widgets (`wxWindow` hierarchy).
|
||||
2. The **OpenGL 3D viewport** (`GLCanvas3D`) — screenshots via the existing
|
||||
framebuffer/thumbnail path.
|
||||
3. **Dear ImGui** immediate-mode controls (gizmo panels, in-canvas overlays,
|
||||
notifications) — recorded as they are drawn, because there is no persistent tree.
|
||||
|
||||
## 2. Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- Let an external, language-agnostic client connect to a running instance and:
|
||||
introspect the UI, locate widgets by stable name, perform input actions, wait on
|
||||
conditions, and capture screenshots (including the 3D view as a separate image).
|
||||
- Be safe by default: disabled unless explicitly enabled; bound to `127.0.0.1` only.
|
||||
- Be testable in CI without a display (pure-logic units behind a mock backend).
|
||||
- Ship a reference Python client, a runnable end-to-end example, protocol docs, and
|
||||
C++ unit tests.
|
||||
|
||||
### Non-Goals (v1)
|
||||
- No headless/offscreen automation — OS input injection needs a focused, visible
|
||||
window (Linux CI requires a display, e.g. Xvfb).
|
||||
- No auth token in v1 (documented future hardening; localhost-only is the boundary).
|
||||
- No per-item coverage of raw-`ImGui::` gizmos (Emboss/SVG/Text). They get
|
||||
window-level coverage; per-item is future work.
|
||||
- No new scripting language embedded in the app; control is purely external over JSON-RPC.
|
||||
- We do **not** modify the existing auth `HttpServer`.
|
||||
|
||||
## 3. Background — existing infrastructure (verified)
|
||||
|
||||
- **`wxUIActionSimulator`** is compiled into the wx build and already used
|
||||
(`src/slic3r/GUI/GUI_ObjectList.cpp:211`). Cross-platform simulated input is available.
|
||||
- **`GLCanvas3D::render_thumbnail()`** (`src/slic3r/GUI/GLCanvas3D.cpp:2210+`) →
|
||||
`render_thumbnail_framebuffer()` (`:6352`) renders the 3D scene into a
|
||||
`ThumbnailData` (RGBA) via an FBO + `glReadPixels`. `debug_output_thumbnail()`
|
||||
(`:6099`) shows the `ThumbnailData → wxImage → PNG` conversion. This is the
|
||||
separate-3D-screenshot path. `Plater::generate_thumbnail()`/`generate_thumbnails()`
|
||||
wrap it.
|
||||
- **`ImGuiWrapper`** (`src/slic3r/GUI/ImGuiWrapper.hpp`) is the chokepoint for nearly
|
||||
all ImGui controls: `button`/`bbl_button`, `checkbox`/`bbl_checkbox`, `combo`,
|
||||
`slider_float`, `input_double`, `radio_button`, `menu_item_with_icon`, plus
|
||||
`begin`/`end` for windows. `imgui_internal.h` is in-tree, exposing
|
||||
`ImGui::GetCurrentContext()->Windows`, item rects, and hovered/active id.
|
||||
- **`HttpServer`** (`src/slic3r/GUI/HttpServer.{hpp,cpp}`, boost::beast, port 13618)
|
||||
is used for cloud auth. **It cannot serve a POST body** — `session::read_body()`
|
||||
reads and discards the body and never replies (`HttpServer.cpp:57-65`). It is
|
||||
effectively GET-only. We will **not** reuse or modify it.
|
||||
- **`OtherInstanceMessageHandler`** (`src/slic3r/GUI/InstanceCheck.{hpp,cpp}`) is the
|
||||
template for "start a localhost listener once the MainFrame exists, post events into
|
||||
the GUI." Useful as a structural reference.
|
||||
- **CLI args** are parsed in `src/OrcaSlicer.cpp` (`CLI::setup`/`CLI::run`) and flow
|
||||
into the GUI run params consumed by `GUI_App::OnInit()`.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
External script / AI agent ──► Python client (orca_automation.py)
|
||||
│ HTTP POST /jsonrpc (JSON-RPC 2.0) on 127.0.0.1:<port>
|
||||
▼
|
||||
AutomationServer (dedicated boost::beast listener; own thread;
|
||||
│ started only when --automation-server is set)
|
||||
│ parse JSON-RPC envelope
|
||||
▼
|
||||
JsonRpcDispatcher (pure logic — method registry; unit-testable)
|
||||
│ marshal each call to the GUI thread via wxGetApp().CallAfter
|
||||
│ + std::promise/future with a per-request timeout
|
||||
▼
|
||||
IUiBackend (interface) ──► WxUiBackend (real, GUI thread) / MockBackend (tests)
|
||||
├─ Introspection : walk wxWindow tree + read ImGui item table → unified JSON
|
||||
├─ Locator : resolve automation-id / predicate → wx widget or ImGui item
|
||||
├─ Actions : raise window, then wxUIActionSimulator click/type/key
|
||||
├─ Sync : wait_for (poll condition) + app.state snapshot
|
||||
└─ Screenshots : wx widget → wxDC→PNG ; 3D view → render_thumbnail()→PNG
|
||||
```
|
||||
|
||||
### Components (new files unless noted)
|
||||
|
||||
All new code lives under `src/slic3r/GUI/Automation/`.
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `AutomationServer.{hpp,cpp}` | Dedicated boost::beast HTTP listener with **POST + body** support; one `POST /jsonrpc` endpoint; returns `application/json`. Localhost-only. Own thread. |
|
||||
| `JsonRpcDispatcher.{hpp,cpp}` | Parse JSON-RPC 2.0; route `method` → handler; build result/error. Depends only on `IUiBackend`. No wx/ImGui includes → unit-testable. |
|
||||
| `IUiBackend.hpp` | Abstract interface: `dump_tree`, `find`, `get_widget`, `click`, `type`, `key`, `wait_for`, `app_state`, `screenshot_window`, `screenshot_viewport3d`. Uses plain structs (no wx types) so tests can mock it. |
|
||||
| `WxUiBackend.{hpp,cpp}` | Real implementation. Runs on GUI thread. Walks `wxWindow` tree, reads the ImGui item table, drives `wxUIActionSimulator`, captures screenshots. |
|
||||
| `MockUiBackend.{hpp,cpp}` (tests) | Deterministic fake tree + recorded actions for unit tests. |
|
||||
| `AutomationRegistry.{hpp,cpp}` | Process-wide `wxWindow* → automation_id` map + reverse lookup; `set_automation_id(win, "id")` helper. Header is dependency-light so widget-construction code can call the helper unconditionally (it is a cheap no-op-safe registration). |
|
||||
| `WidgetSerializer.{hpp,cpp}` | `wxWindow` → JSON node (name/id, class, label, screen-rect, enabled, shown, value via RTTI). |
|
||||
| `ImGuiItemTable.{hpp,cpp}` | Per-frame recorder of ImGui items + live-window enumeration. Populated from `ImGuiWrapper`; read on GUI thread. |
|
||||
|
||||
### Touch points in existing files
|
||||
- `src/slic3r/GUI/ImGuiWrapper.cpp` — add recording hooks inside the wrapped widget
|
||||
methods and `begin`/`end`, **guarded by an `is_automation_enabled()` flag** so there
|
||||
is zero overhead and zero behavior change when automation is off.
|
||||
- `src/slic3r/GUI/GUI_App.{hpp,cpp}` — own the `AutomationServer`; start it in
|
||||
`OnInit()` only when the flag is set; stop it on exit. Expose
|
||||
`is_automation_enabled()`.
|
||||
- `src/OrcaSlicer.cpp` — parse `--automation-server[=PORT]`; pass through GUI run params.
|
||||
- A handful of widget-construction sites (Slice/Export buttons, preset combos, main
|
||||
tabs, common dialog OK/Cancel, the 3D canvas) — add `set_automation_id(...)` calls
|
||||
(~15-20 widgets in v1).
|
||||
- CMake: add the new `Automation/` sources to the GUI target; add the unit-test target.
|
||||
|
||||
## 5. Transport & Protocol
|
||||
|
||||
- **Transport:** HTTP/1.1 on `127.0.0.1:<port>` (default **13619**, adjacent to the
|
||||
auth server's 13618). Single endpoint: `POST /jsonrpc`, body is a JSON-RPC 2.0
|
||||
request, response is a JSON-RPC 2.0 result/error. `GET /` returns a tiny health/version page.
|
||||
- **Protocol:** JSON-RPC 2.0. `id`, `method`, `params`. Batch not required in v1.
|
||||
|
||||
### v1 methods
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `automation.version` | — | `{version, protocol, capabilities[]}` |
|
||||
| `tree.dump` | `{root?, max_depth?, visible_only?, include_imgui?}` | tree of nodes (wx + imgui) |
|
||||
| `tree.find` | `{name?, class?, label?, value?, backend?}` | `[node...]` matches |
|
||||
| `widget.get` | `{target}` | single node detail |
|
||||
| `input.click` | `{target, button?=left, double?=false, modifiers?[]}` | `{ok}` |
|
||||
| `input.type` | `{target?, text}` | `{ok}` |
|
||||
| `input.key` | `{keys}` e.g. `"ctrl+s"` or `["ctrl","s"]` | `{ok}` |
|
||||
| `sync.wait_for` | `{target, state: exists\|visible\|enabled\|value, value?, timeout_ms?=5000, poll_ms?=100}` | `{ok, elapsed_ms}` |
|
||||
| `app.state` | — | `{active_tab, project_loaded, slicing, slice_progress, modal_dialog?, foreground}` |
|
||||
| `screenshot.window` | `{target?}` (default main frame) | `{png_base64, width, height}` |
|
||||
| `screenshot.viewport3d` | `{plate?, width?, height?}` | `{png_base64, width, height}` |
|
||||
|
||||
### Node shape (unified for wx and ImGui)
|
||||
```json
|
||||
{
|
||||
"backend": "wx" | "imgui",
|
||||
"id": "btn_slice", // automation id if set, else derived path id
|
||||
"path": "MainFrame/.../btn_slice", // stable-ish positional path
|
||||
"class": "Button", // wx class name or imgui item type
|
||||
"label": "Slice plate",
|
||||
"rect": { "x": 100, "y": 200, "w": 120, "h": 32 }, // screen coords
|
||||
"enabled": true,
|
||||
"visible": true,
|
||||
"value": "PLA", // when applicable (text/choice/check/slider)
|
||||
"children": [ ... ] // wx only; imgui items are flat under their window
|
||||
}
|
||||
```
|
||||
|
||||
### Error model (JSON-RPC `error.code`)
|
||||
- `-32700` parse error, `-32601` method not found, `-32602` invalid params (standard).
|
||||
- Application codes: `1001` widget/target not found, `1002` target not actionable
|
||||
(disabled/hidden), `1003` wait timeout, `1004` GUI thread busy/timeout,
|
||||
`1005` screenshot failed, `1006` automation feature disabled.
|
||||
|
||||
## 6. Threading model
|
||||
|
||||
- `AutomationServer` runs on its own thread and accepts connections; the dispatcher
|
||||
parses on that thread.
|
||||
- **Every** call touching wx/ImGui/GL is marshaled to the GUI thread with
|
||||
`wxGetApp().CallAfter([...]{ ... })`; the server thread blocks on a `std::future`
|
||||
with a per-request timeout (default 5 s; `wait_for` uses its own larger budget).
|
||||
Timeout → error `1004`.
|
||||
- This is mandatory: wx widgets, the ImGui context, and the GL context are not
|
||||
thread-safe and are owned by the GUI thread.
|
||||
- `CallAfter` is serviced even while modal dialogs run (nested event loop), so
|
||||
automation can interact with dialogs.
|
||||
|
||||
## 7. Widget locator & automation IDs (wxWidgets)
|
||||
|
||||
- **Stable IDs:** `set_automation_id(window, "btn_slice")` registers the widget in
|
||||
`AutomationRegistry`. Stored in a side map keyed by `wxWindow*` (not via `SetName`,
|
||||
to avoid any coupling with wx's name-based lookups). Registration is removed on
|
||||
widget destruction (bind to `wxEVT_DESTROY` or prune lazily on lookup).
|
||||
- **Derived IDs:** for un-instrumented widgets, `WidgetSerializer` derives a positional
|
||||
`path` (e.g. `MainFrame/Panel[2]/Button[0]`) so an AI agent can still target anything.
|
||||
Named IDs are the preferred, stable path.
|
||||
- **Locator resolution order:** exact automation id → exact path → predicate match
|
||||
(name/class/label/value). Ambiguous matches return the list via `tree.find`; action
|
||||
methods require a unique match or error `1001`.
|
||||
- **v1 instrumented widgets (~15-20):** Slice/Export buttons, printer & filament preset
|
||||
combos, the main tab buttons (`tp3DEditor`/`tpPreview`/`tpMonitor`/…), Add/Import,
|
||||
common dialog OK/Cancel/Yes/No, the `GLCanvas3D` itself.
|
||||
|
||||
## 8. ImGui coverage (v1 = wrapper items + window introspection)
|
||||
|
||||
- **Item recording:** inside each `ImGuiWrapper` wrapped method, when
|
||||
`is_automation_enabled()` is true, append the just-drawn item to a per-frame
|
||||
`ImGuiItemTable` entry: `{window_name, label/id, type, rect, enabled, value}`.
|
||||
Item rect comes from `ImGui::GetItemRectMin/Max()` (ImGui display coords) mapped to
|
||||
**screen** coords via the `GLCanvas3D` client origin (`ClientToScreen`) and DPI scale.
|
||||
- **Window enumeration:** via `imgui_internal.h`, enumerate `GetCurrentContext()->Windows`
|
||||
for window name, rect, visibility, plus the global hovered/active item id.
|
||||
- **Double-buffering:** the table is swapped at frame end (`ImGuiWrapper::render`) so
|
||||
readers see a complete frame. Reads happen on the GUI thread (after marshaling), same
|
||||
thread as rendering, so a simple front/back swap suffices.
|
||||
- **Freshness:** because items exist only while drawn, before an ImGui tree read or
|
||||
action the backend forces a canvas refresh and flushes events so the latest frame is
|
||||
captured. `sync.wait_for` can poll for an ImGui item to appear (e.g. after opening a
|
||||
gizmo).
|
||||
- **Actions:** an ImGui target resolves to its recorded screen rect; `input.click`/
|
||||
`input.type` use `wxUIActionSimulator` on that rect — identical action path to wx,
|
||||
different rect source. Typing into an ImGui input works because simulated keystrokes
|
||||
flow through the existing `ImGuiWrapper::update_key_data` bridge once the field is
|
||||
focused by a click.
|
||||
- **Limitation (documented):** raw-`ImGui::` gizmos (Emboss, SVG, Text) are covered at
|
||||
the **window** level only in v1; per-item instrumentation is future work.
|
||||
|
||||
## 9. Screenshots
|
||||
|
||||
- **`screenshot.window`:** capture a `wxWindow` (default: main frame) via
|
||||
`wxClientDC`/`wxWindowDC` → `wxBitmap` → `wxImage` → PNG → base64. Works for native
|
||||
widgets but **not** for the GL canvas region (returns black there) — hence the
|
||||
separate 3D method.
|
||||
- **`screenshot.viewport3d`:** reuse `GLCanvas3D::render_thumbnail()` (FBO +
|
||||
`glReadPixels`) → `ThumbnailData` → `wxImage` (per `debug_output_thumbnail`) → PNG →
|
||||
base64. Optional `plate`, `width`, `height` params. Runs on the GUI thread with the GL
|
||||
context current.
|
||||
|
||||
## 10. Activation & security
|
||||
|
||||
- **Off by default.** Enabled by CLI flag `--automation-server[=PORT]` (default port
|
||||
13619). (An app-config/Preferences toggle may be added later; v1 is flag-only.)
|
||||
- **Bind `127.0.0.1` only.** No external interface.
|
||||
- **No token in v1** (per decision); documented as a recommended future hardening,
|
||||
along with an optional `--automation-token`.
|
||||
- When disabled: no listener, no thread, and all `ImGuiWrapper` recording hooks are
|
||||
skipped — **zero** runtime overhead and **zero** behavior change. This satisfies the
|
||||
project's "features gated by options must not affect existing behavior when disabled"
|
||||
constraint.
|
||||
|
||||
## 11. Testability
|
||||
|
||||
- `JsonRpcDispatcher` depends only on `IUiBackend` and has **no** wx/ImGui/GL includes.
|
||||
- **C++ unit tests (Catch2), display-free, run in CI:**
|
||||
- JSON-RPC envelope parse/validate/dispatch (good + malformed input, error codes).
|
||||
- Method routing and param validation for every v1 method against `MockUiBackend`.
|
||||
- `WidgetSerializer` node shape (fed a synthetic node model, not real wx widgets).
|
||||
- Locator resolution: exact id, path, predicate, ambiguity, not-found.
|
||||
- The only piece needing a real GUI is `WxUiBackend`; it is exercised by the manual
|
||||
end-to-end example, not by CI unit tests.
|
||||
|
||||
## 12. Deliverables
|
||||
|
||||
- **C++:** the `Automation/` components, `ImGuiWrapper` recording hooks, widget
|
||||
instrumentation, CLI flag plumbing, `GUI_App` lifecycle, CMake wiring.
|
||||
- **`tools/automation/orca_automation.py`:** reference Python client wrapping the
|
||||
JSON-RPC calls (`connect`, `version`, `dump_tree`, `find`, `click`, `type`, `key`,
|
||||
`wait_for`, `app_state`, `screenshot`, `screenshot_3d`).
|
||||
- **`tools/automation/example_slice.py`:** runnable end-to-end flow — launch OrcaSlicer
|
||||
with the flag, load a model, click Slice, `wait_for` completion, save a 3D-preview PNG.
|
||||
Doubles as a manual smoke test.
|
||||
- **`doc/automation.md`:** protocol reference (methods, params, results, error codes),
|
||||
node shape, automation-id naming conventions, ImGui notes, platform/display caveats.
|
||||
- **`tests/`:** Catch2 unit-test target for the dispatch/serialize/locator logic.
|
||||
|
||||
## 13. New / changed file inventory
|
||||
|
||||
**New**
|
||||
- `src/slic3r/GUI/Automation/AutomationServer.{hpp,cpp}`
|
||||
- `src/slic3r/GUI/Automation/JsonRpcDispatcher.{hpp,cpp}`
|
||||
- `src/slic3r/GUI/Automation/IUiBackend.hpp`
|
||||
- `src/slic3r/GUI/Automation/WxUiBackend.{hpp,cpp}`
|
||||
- `src/slic3r/GUI/Automation/AutomationRegistry.{hpp,cpp}`
|
||||
- `src/slic3r/GUI/Automation/WidgetSerializer.{hpp,cpp}`
|
||||
- `src/slic3r/GUI/Automation/ImGuiItemTable.{hpp,cpp}`
|
||||
- `tools/automation/orca_automation.py`
|
||||
- `tools/automation/example_slice.py`
|
||||
- `doc/automation.md`
|
||||
- `tests/automation/` (Catch2 target) + `MockUiBackend.{hpp,cpp}`
|
||||
|
||||
**Changed**
|
||||
- `src/slic3r/GUI/ImGuiWrapper.cpp` (guarded recording hooks)
|
||||
- `src/slic3r/GUI/GUI_App.{hpp,cpp}` (server lifecycle, `is_automation_enabled()`)
|
||||
- `src/OrcaSlicer.cpp` (CLI flag)
|
||||
- ~15-20 widget-construction sites (`set_automation_id`)
|
||||
- `src/slic3r/GUI/CMakeLists.txt` + `tests/CMakeLists.txt`
|
||||
|
||||
## 14. Known constraints & limitations
|
||||
|
||||
- OS input injection requires the OrcaSlicer window **focused and visible**; the backend
|
||||
raises/focuses the main window before injecting. Linux CI needs a display (Xvfb).
|
||||
- Input is asynchronous at the OS level; correctness relies on `sync.wait_for` rather
|
||||
than fixed sleeps.
|
||||
- ImGui items are only addressable while their host panel is drawn.
|
||||
- Raw-`ImGui::` gizmos: window-level only in v1.
|
||||
- Single-client assumption in v1 (serialized request handling); no concurrent sessions
|
||||
contract.
|
||||
|
||||
## 15. Future work (out of scope for v1)
|
||||
|
||||
- Optional auth token + Preferences toggle.
|
||||
- WebSocket channel for server-push events (slice progress, dialog-appeared).
|
||||
- Per-item instrumentation for raw-`ImGui::` gizmos.
|
||||
- An MCP server wrapping the JSON-RPC client for direct AI-agent integration.
|
||||
- Optional integration of Dear ImGui Test Engine for deterministic ImGui interaction.
|
||||
|
||||
## 16. Verification plan
|
||||
|
||||
- **CI:** Catch2 unit tests (dispatch/serialize/locator) pass with no display.
|
||||
- **Manual / e2e:** run `tools/automation/example_slice.py` against a built OrcaSlicer
|
||||
launched with `--automation-server`; confirm model loads, Slice runs, `wait_for`
|
||||
returns on completion, and both a wx-window PNG and a 3D-viewport PNG are produced.
|
||||
- **Regression:** build and run with automation **off**; confirm no new threads, no
|
||||
listener, and ImGui rendering is byte-for-byte unchanged (hooks compiled out of the
|
||||
hot path via the disabled flag).
|
||||
File diff suppressed because one or more lines are too long
@@ -1,470 +1 @@
|
||||
#Content
|
||||
{
|
||||
overflow-y:auto;
|
||||
padding: 0 10px 0 20px; /* ORCA Specify & Reduce horizontal paddings to fit 4 items per row */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.OneVendorBlock {
|
||||
position: relative;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.OneVendorBlock:last-of-type {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.BlockBanner
|
||||
{
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0px;
|
||||
border-bottom: 2px solid var(--main-color);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
background-color: var(--bg-color-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.BannerBtns
|
||||
{
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-right: 5px; /* ORCA align buttons with end of horizontal separator/line */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.BlockBanner a
|
||||
{
|
||||
line-height: 30px;
|
||||
height: 30px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
padding: 0px 10px;
|
||||
color: var(--fg-color-text);
|
||||
}
|
||||
|
||||
.BlockBanner .modelCount {
|
||||
margin: 0 15px 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
height: 15px;
|
||||
color: var(--fg-color-label);
|
||||
}
|
||||
|
||||
.VendorCheckbox {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.PrinterArea
|
||||
{
|
||||
padding: 7px 0px; /* ORCA Reduce horizontal paddings to fit 4 items per row */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.PrinterBlock
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap:10px;
|
||||
padding: 15px 10px 10px 10px;
|
||||
background-color: var(--bg-color-secondary);
|
||||
position: relative;
|
||||
border: 1px solid transparent
|
||||
}
|
||||
|
||||
.PrinterBlock:hover {
|
||||
background-color: var(--focus-bg-item);
|
||||
border-color:var(--main-color);
|
||||
}
|
||||
|
||||
.PImg {
|
||||
width:120px; /* ORCA use covers as 120x120px but use source file as 240x240 for better quality on hidpi */
|
||||
height:120px; /* ORCA fit image to fill frame */
|
||||
}
|
||||
|
||||
.PrinterInfo,
|
||||
.PrinterInfoMark {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
opacity: 0;
|
||||
border-radius: 11px;
|
||||
line-height: 19px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.PrinterInfo {
|
||||
--card-animation-delay: .8s; /* open info with delay on list / compact view to prevent them appear while mouse movements */
|
||||
--card-info-height: fit-content;
|
||||
left: 4px;
|
||||
width: auto;
|
||||
z-index: 9998;
|
||||
height: var(--card-info-height);
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-color);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
color: var(--fg-color-text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#Content[layout="2"] .PrinterInfo {
|
||||
--card-animation-delay: .3s;
|
||||
--card-info-height: 116px;
|
||||
}
|
||||
|
||||
.PrinterInfo .title {font-weight: 700}
|
||||
.PrinterInfo .value {font-weight: 400}
|
||||
|
||||
.PrinterInfoMark:hover + .PrinterInfo {
|
||||
animation: infoCard 0s forwards var(--card-animation-delay);
|
||||
}
|
||||
|
||||
@keyframes infoCard {100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
||||
}}
|
||||
|
||||
.PrinterInfoMark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--main-color);
|
||||
border: 1px solid var(--main-color);
|
||||
z-index: 9999;
|
||||
color: #FFF;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.PrinterBlock:hover .PrinterInfoMark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.PrinterBlock:hover .PrinterInfoMark:hover {
|
||||
background: var(--main-color-hover);
|
||||
}
|
||||
|
||||
.ModelCheckBox
|
||||
{
|
||||
position: absolute;
|
||||
height: 6px;
|
||||
bottom: 0;
|
||||
left: 10%;
|
||||
width: 80%;
|
||||
background: var(--button-bg-hover)
|
||||
}
|
||||
|
||||
.ModelCheckBox.ModelCheckBoxSelected
|
||||
{
|
||||
background: var(--main-color-fixed)
|
||||
}
|
||||
|
||||
img.ModelThumbnail
|
||||
{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.PName
|
||||
{
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* ORCA */
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
color: var(--fg-color-text);
|
||||
}
|
||||
|
||||
.pNozzel
|
||||
{
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content:flex-start;
|
||||
color: #5A5A5A;
|
||||
padding-left: 0px; /* ORCA Align checkboxes with with model text */
|
||||
}
|
||||
|
||||
.pNozzel input
|
||||
{
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.LayoutSelector {
|
||||
position: absolute;
|
||||
right:21px;
|
||||
top:14px;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabGroup {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-color-alt);
|
||||
}
|
||||
|
||||
.LayoutSelector .icon16 {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabButton {
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabButton.selected {background: var(--main-color)}
|
||||
.LayoutSelector .TabButton.selected:hover {background: var(--main-color-hover)}
|
||||
.LayoutSelector .TabButton.selected .icon16 {background: #FFF}
|
||||
|
||||
.LayoutSelector .TabButton:nth-of-type(1) .icon16 {--icon-url: var(--icon-layout-list)}
|
||||
.LayoutSelector .TabButton:nth-of-type(2) .icon16 {--icon-url: var(--icon-layout-compact)}
|
||||
.LayoutSelector .TabButton:nth-of-type(3) .icon16 {--icon-url: var(--icon-layout-cover)}
|
||||
|
||||
/* LAYOUT */
|
||||
#Content[layout="compact-list"] .PrinterArea {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
#Content[layout="compact-list"] .PImg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#Content[layout="compact-list"] .OneVendorBlock {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#Content[layout="compact-cover"] .PrinterArea {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
#Content[layout="compact-cover"] .PImg {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .PName {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .PrinterBlock {
|
||||
flex-direction: row;
|
||||
padding: 5px 5px 5px 18px;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .ModelCheckBox {
|
||||
width: 6px;
|
||||
height: 80%;
|
||||
left:0;
|
||||
top:10%
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .OneVendorBlock:last-of-type {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
/*-----Notice-----*/
|
||||
#NoticeMask
|
||||
{
|
||||
background-color: #000;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.05;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#NoticeBody
|
||||
{
|
||||
display: none;
|
||||
width: 400px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background-color: inherit;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 200px;
|
||||
margin-left: -200px;
|
||||
}
|
||||
|
||||
#NoticeBar
|
||||
{
|
||||
background-color:#00f0d8;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#NoticeContent
|
||||
{
|
||||
padding: 4mm 10mm;
|
||||
}
|
||||
|
||||
|
||||
#NoticeBtns
|
||||
{
|
||||
margin-top: 4mm;
|
||||
display: flex;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
|
||||
.search {
|
||||
position: absolute;
|
||||
left:66px;
|
||||
top: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
z-index: 99;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search:focus-within,
|
||||
.search[hasvalue="1"] {
|
||||
width: calc(100% - 194px);
|
||||
}
|
||||
|
||||
.searchTerm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px 5px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--button-bg-normal);
|
||||
border: 1px solid var(--button-bg-normal);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.searchTerm {
|
||||
transition: background-color .2s
|
||||
}
|
||||
}
|
||||
|
||||
.searchTerm,
|
||||
.search-placeholder {
|
||||
line-height: 24px; /* ORCA center text vertically */
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search:focus-within .searchTerm,
|
||||
.search[hasvalue="1"] .searchTerm {
|
||||
padding-left:33px;
|
||||
background: var(--bg-color);
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.search[hasvalue="1"]:not(:focus-within, :hover) .searchTerm {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.search:not(:focus-within, [hasvalue="1"]) .searchTerm {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search:not(:focus-within, [hasvalue="1"]) .searchTerm:hover {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: var(--fg-color-disabled);
|
||||
left: 33px;
|
||||
}
|
||||
|
||||
.searchTerm:not(:placeholder-shown) + .search-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.search-icon,
|
||||
.search-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
left: 9px;
|
||||
--icon-url: var(--icon-search);
|
||||
}
|
||||
|
||||
.SidebarBtn {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 14px;
|
||||
padding: 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.SidebarBtn .icon16 {
|
||||
--icon-url: var(--icon-sidebar);
|
||||
}
|
||||
|
||||
#SidebarContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -240px;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#SidebarContainer {
|
||||
transition: background-color .2s, left .2s
|
||||
}
|
||||
}
|
||||
|
||||
#SidebarContainer[open="1"] {
|
||||
left: 0px;
|
||||
pointer-events: all;
|
||||
background: rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
#Sidebar {
|
||||
flex: 0 0 220px;
|
||||
background: var(--bg-color);
|
||||
box-shadow: 5px 0 20px rgba(0,0,0,.2);
|
||||
padding: 15px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#Sidebar .title {
|
||||
font-size: 17px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
padding: 0 0 5px 20px;
|
||||
}
|
||||
|
||||
#Sidebar .SidebarItem {
|
||||
width: 100%;
|
||||
padding: 2px 10px 2px 20px;
|
||||
color:var(--fg-color-text);
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#Sidebar .SidebarItem:hover {
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
#SidebarContainer .back {
|
||||
flex: 1;
|
||||
}
|
||||
/* UNIQUE STYLES */
|
||||
@@ -1,395 +1,15 @@
|
||||
// UNIQUE FUNCTIONS
|
||||
|
||||
// Keep in here for future additions
|
||||
function OnInit()
|
||||
{
|
||||
//let strInput=JSON.stringify(cData);
|
||||
//HandleModelList(cData);
|
||||
|
||||
TranslatePage();
|
||||
|
||||
RequestProfile();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function RequestProfile()
|
||||
{
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="request_userguide_profile";
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
function HandleStudio( pVal )
|
||||
{
|
||||
// alert(strInput);
|
||||
// alert(JSON.stringify(strInput));
|
||||
//
|
||||
// let pVal=IsJson(strInput);
|
||||
// if(pVal==null)
|
||||
// {
|
||||
// alert("Msg Format Error is not Json");
|
||||
// return;
|
||||
// }
|
||||
|
||||
let strCmd=pVal['command'];
|
||||
//alert(strCmd);
|
||||
|
||||
if(strCmd=='response_userguide_profile')
|
||||
{
|
||||
HandleModelList(pVal['response']);
|
||||
}
|
||||
}
|
||||
|
||||
function ShowPrinterThumb(pItem, strImg)
|
||||
{
|
||||
$(pItem).attr('src',strImg);
|
||||
$(pItem).attr('onerror',null);
|
||||
}
|
||||
|
||||
function ChooseModel( vendor, ModelName )
|
||||
{
|
||||
let ChooseItem=$(".ModelCheckBox[vendor='"+vendor+"'][model='"+ModelName+"']");
|
||||
|
||||
if(ChooseItem!=null)
|
||||
{
|
||||
if( $(ChooseItem).hasClass('ModelCheckBoxSelected') )
|
||||
$(ChooseItem).removeClass('ModelCheckBoxSelected');
|
||||
else
|
||||
$(ChooseItem).addClass('ModelCheckBoxSelected');
|
||||
|
||||
SetModelSelect(vendor, ModelName, $(ChooseItem).hasClass('ModelCheckBoxSelected'));
|
||||
}
|
||||
}
|
||||
|
||||
function HandleModelList( pVal )
|
||||
{
|
||||
if( !pVal.hasOwnProperty("model") )
|
||||
return;
|
||||
|
||||
pModel=pVal['model'];
|
||||
|
||||
// ORCA ensure list correctly ordered
|
||||
pModel = pModel.sort((a, b)=>(a["vendor"].localeCompare(b["vendor"])))
|
||||
pModel = [ // move custom printers to top
|
||||
...pModel.filter(i=>i.vendor === "Custom"),
|
||||
...pModel.filter(i=>i.vendor !== "Custom")
|
||||
];
|
||||
|
||||
let nTotal=pModel.length;
|
||||
let ModelHtml={};
|
||||
for(let n=0;n<nTotal;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
|
||||
let strVendor=OneModel['vendor'];
|
||||
|
||||
//Add Vendor Html Node
|
||||
if($(".OneVendorBlock[vendor='"+strVendor+"']").length==0)
|
||||
{
|
||||
let sVV=strVendor;
|
||||
if( sVV=="BBL" )
|
||||
sVV="Bambu Lab";
|
||||
if( sVV=="Custom")
|
||||
sVV="Custom Printer";
|
||||
if( sVV=="Other")
|
||||
sVV="Orca colosseum";
|
||||
|
||||
let HtmlNewVendor='<div class="OneVendorBlock" Vendor="'+strVendor+'">'+
|
||||
'<div class="BlockBanner">'+
|
||||
' <a>'+sVV+'</a>'+
|
||||
' <div class="BannerBtns" onClick="ChooseVendor('+"\'"+strVendor+"\'"+')">'+
|
||||
' <div class="modelCount"></div>' +
|
||||
' <input type="checkbox" class="VendorCheckbox"/>'+
|
||||
' </div>'+
|
||||
//' <div class="BannerBtns">'+
|
||||
//' <div class="ButtonStyleConfirm ButtonTypeWindow trans" tid="t11" onClick="SelectPrinterAll('+"\'"+strVendor+"\'"+')">all</div>'+
|
||||
//' <div class="ButtonStyleRegular ButtonTypeWindow trans" tid="t12" onClick="SelectPrinterNone('+"\'"+strVendor+"\'"+')">none</div>'+
|
||||
//' </div>'+
|
||||
'</div>'+
|
||||
'<div class="PrinterArea"> '+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
|
||||
$('#Content').append(HtmlNewVendor);
|
||||
}
|
||||
|
||||
let ModelName=OneModel['model'];
|
||||
|
||||
//Collect Html Node Nozzel Html
|
||||
if( !ModelHtml.hasOwnProperty(strVendor))
|
||||
ModelHtml[strVendor]='';
|
||||
|
||||
ModelHtml[strVendor]+=CreatePrinterBlock(OneModel); // ORCA
|
||||
}
|
||||
|
||||
//Update Nozzel Html Append
|
||||
for( let key in ModelHtml )
|
||||
{
|
||||
$(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").append( ModelHtml[key] );
|
||||
}
|
||||
|
||||
|
||||
//Update Checkbox
|
||||
for(let m=0;m<nTotal;m++)
|
||||
{
|
||||
let OneModel=pModel[m];
|
||||
|
||||
let SelectList=OneModel['nozzle_selected'];
|
||||
if(SelectList!='')
|
||||
{
|
||||
ChooseModel(OneModel['vendor'], OneModel['model']);
|
||||
}
|
||||
}
|
||||
|
||||
const $SidebarVendors = $('#SidebarVendors');
|
||||
let SidebarHTML = "";
|
||||
$(`.OneVendorBlock`).each((i, el)=>{
|
||||
UpdateVendorCheckbox(el.getAttribute("vendor"));
|
||||
SidebarHTML +=`<div class="SidebarItem" onclick="scrollToVendor(this.textContent)">${el.getAttribute('vendor')}</div>`;
|
||||
});
|
||||
$SidebarVendors.html(SidebarHTML)
|
||||
|
||||
// let AlreadySelect=$(".ModelCheckBoxSelected");
|
||||
// let nSelect=AlreadySelect.length;
|
||||
// if(nSelect==0)
|
||||
// {
|
||||
// $("div.OneVendorBlock[vendor='"+BBL+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
// }
|
||||
|
||||
TranslatePage();
|
||||
}
|
||||
|
||||
function scrollToVendor(vendor) {
|
||||
const el = $(".OneVendorBlock[vendor='"+vendor+"']")[0];
|
||||
if (el){
|
||||
document.getElementById('SidebarContainer').setAttribute('open', '0');
|
||||
document.getElementById('Content').scrollTo({top: el.offsetTop, behavior: "smooth"});
|
||||
}
|
||||
}
|
||||
|
||||
function SetModelSelect(vendor, model, checked) {
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor) && !checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor) && checked) {
|
||||
ModelNozzleSelected[vendor] = {};
|
||||
}
|
||||
|
||||
let oVendor = ModelNozzleSelected[vendor];
|
||||
if (oVendor.hasOwnProperty(model) || checked) {
|
||||
oVendor[model] = checked;
|
||||
}
|
||||
|
||||
UpdateVendorCheckbox(vendor)
|
||||
}
|
||||
|
||||
function GetModelSelect(vendor, model) {
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let oVendor = ModelNozzleSelected[vendor];
|
||||
if (!oVendor.hasOwnProperty(model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return oVendor[model];
|
||||
}
|
||||
|
||||
function FilterModelList(keyword) {
|
||||
|
||||
//Save checkbox state
|
||||
let ModelSelect = $('.ModelCheckBox');
|
||||
for (let n = 0; n < ModelSelect.length; n++) {
|
||||
let OneItem = ModelSelect[n];
|
||||
|
||||
let strModel = OneItem.getAttribute("model");
|
||||
|
||||
let strVendor = OneItem.getAttribute("vendor");
|
||||
|
||||
SetModelSelect(strVendor, strModel, $(OneItem).hasClass('ModelCheckBoxSelected'));
|
||||
}
|
||||
|
||||
$('.search')[0].setAttribute("hasvalue", keyword ? "1" : "0")
|
||||
|
||||
let nTotal = pModel.length;
|
||||
let ModelHtml = {};
|
||||
let kwSplit = keyword.toLowerCase().match(/\S+/g) || [];
|
||||
|
||||
$('#Content').empty();
|
||||
for (let n = 0; n < nTotal; n++) {
|
||||
let OneModel = pModel[n];
|
||||
|
||||
let strVendor = OneModel['vendor'];
|
||||
let search = (OneModel['name'] + '\0' + strVendor).toLowerCase();
|
||||
|
||||
if (!kwSplit.every(s => search.includes(s)))
|
||||
continue;
|
||||
|
||||
//Add Vendor Html Node
|
||||
if ($(".OneVendorBlock[vendor='" + strVendor + "']").length == 0) {
|
||||
let sVV = strVendor;
|
||||
if (sVV == "BBL")
|
||||
sVV = "Bambu Lab";
|
||||
if (sVV == "Custom")
|
||||
sVV = "Custom Printer";
|
||||
if (sVV == "Other")
|
||||
sVV = "Orca colosseum";
|
||||
|
||||
let HtmlNewVendor = '<div class="OneVendorBlock" Vendor="' + strVendor + '">' +
|
||||
'<div class="BlockBanner">' +
|
||||
' <a>' + sVV + '</a>' +
|
||||
' <div class="BannerBtns" onClick="ChooseVendor('+"\'"+strVendor+"\'"+')">'+
|
||||
' <div class="modelCount"></div>' +
|
||||
' <input type="checkbox" class="VendorCheckbox"/>'+
|
||||
' </div>'+
|
||||
//' <div class="BannerBtns">' +
|
||||
//' <div class="ButtonStyleConfirm ButtonTypeWindow trans" tid="t11" onClick="SelectPrinterAll(' + "\'" + strVendor + "\'" + ')">all</div>' +
|
||||
//' <div class="ButtonStyleRegular ButtonTypeWindow trans" tid="t12" onClick="SelectPrinterNone(' + "\'" + strVendor + "\'" + ')">none</div>' +
|
||||
//' </div>' +
|
||||
'</div>' +
|
||||
'<div class="PrinterArea"> ' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$('#Content').append(HtmlNewVendor);
|
||||
}
|
||||
|
||||
//Collect Html Node Nozzel Html
|
||||
if (!ModelHtml.hasOwnProperty(strVendor))
|
||||
ModelHtml[strVendor] = '';
|
||||
|
||||
ModelHtml[strVendor]+=CreatePrinterBlock(OneModel); // ORCA
|
||||
}
|
||||
|
||||
//Update Nozzel Html Append
|
||||
for (let key in ModelHtml) {
|
||||
let obj = $(".OneVendorBlock[vendor='" + key + "'] .PrinterArea");
|
||||
obj.empty();
|
||||
obj.append(ModelHtml[key]);
|
||||
}
|
||||
|
||||
|
||||
//Update Checkbox
|
||||
ModelSelect = $('.ModelCheckBox');
|
||||
for (let n = 0; n < ModelSelect.length; n++) {
|
||||
let OneItem = ModelSelect[n];
|
||||
|
||||
let strModel = OneItem.getAttribute("model");
|
||||
let strVendor = OneItem.getAttribute("vendor");
|
||||
|
||||
let checked = GetModelSelect(strVendor, strModel);
|
||||
|
||||
if (checked)
|
||||
$(OneItem).addClass('ModelCheckBoxSelected');
|
||||
else
|
||||
$(OneItem).removeClass('ModelCheckBoxSelected');
|
||||
}
|
||||
|
||||
const $SidebarVendors = $('#SidebarVendors');
|
||||
let SidebarHTML = "";
|
||||
$(`.OneVendorBlock`).each((i, el)=>{
|
||||
UpdateVendorCheckbox(el.getAttribute("vendor"));
|
||||
SidebarHTML +=`<div class="SidebarItem" onclick="scrollToVendor(this.textContent)">${el.getAttribute('vendor')}</div>`;
|
||||
});
|
||||
$SidebarVendors.html(SidebarHTML)
|
||||
|
||||
const $content = $('#Content');
|
||||
$content.css("padding-right", $content[0].scrollHeight > $content[0].clientHeight ? "10px" : "20px");
|
||||
|
||||
// let AlreadySelect=$(".ModelCheckBoxSelected");
|
||||
// let nSelect=AlreadySelect.length;
|
||||
// if(nSelect==0)
|
||||
// {
|
||||
// $("div.OneVendorBlock[vendor='"+BBL+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
// }
|
||||
|
||||
TranslatePage();
|
||||
}
|
||||
|
||||
function CreatePrinterBlock(OneModel)
|
||||
{
|
||||
// ORCA use single functuon to create blocks to simplify code
|
||||
let vendor = OneModel['vendor']
|
||||
vendorName = vendor=="BBL" ? "Bambu Lab" : vendor=="Custom" ? "Generic Printer" : vendor;
|
||||
|
||||
let modelName = OneModel['name'];
|
||||
// Most of it unneeded. this can be applied in profiles
|
||||
if( vendor=="Custom")
|
||||
modelName = modelName.split(" ")[1];
|
||||
// these uses different case in name; seckit, ratrig, blocks
|
||||
else if (modelName.toLowerCase().startsWith(vendorName.toLowerCase()))
|
||||
modelName = modelName.slice(vendorName.length);
|
||||
// these not matches. have to fix in profiles to reduce conditions in here;
|
||||
else if (vendor == "MagicMaker" && modelName.startsWith("MM"))
|
||||
modelName = modelName.slice(("MM").length);
|
||||
else if (vendor == "OrcaArena")
|
||||
modelName = modelName.slice(("Orca Arena").length);
|
||||
else if (vendor == "RolohaunDesign" && modelName.startsWith("Rolohaun"))
|
||||
modelName = modelName.slice(("Rolohaun").length);
|
||||
|
||||
return '<div class="PrinterBlock" onClick="ChooseModel(\''+vendor+'\',\''+OneModel['model']+'\')">'+
|
||||
'<div class="PImg">'+
|
||||
'<img class="ModelThumbnail" src="' + OneModel['cover'] + '" />'+
|
||||
'</div>'+
|
||||
'<div class="PrinterInfoMark">?</div>'+
|
||||
'<div class="PrinterInfo">'+
|
||||
//' <div class="title trans">Print volume</div>'+
|
||||
//' <div class="value">' + OneModel['printable_height'] + '</div>'+
|
||||
' <div class="title trans">Nozzle</div>'+
|
||||
' <div class="value">' + OneModel['nozzle_diameter'].replaceAll(";", " · ") + '</div>'+
|
||||
'</div>'+
|
||||
'<div style="display: flex;">'+
|
||||
' <div class="ModelCheckBox" vendor="' +vendor+ '" model="'+OneModel['model']+'"></div>'+
|
||||
' <div class="PName">'+ modelName +'</div>'+ // ><p>'+ vendorName +'</p>
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function SelectPrinterAll( sVendor )
|
||||
{
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").each(function() {
|
||||
let strModel = this.getAttribute("model");
|
||||
SetModelSelect(sVendor, strModel, true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function SelectPrinterNone( sVendor )
|
||||
{
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").removeClass('ModelCheckBoxSelected');
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").each(function() {
|
||||
let strModel = this.getAttribute("model");
|
||||
SetModelSelect(sVendor, strModel, false);
|
||||
});
|
||||
}
|
||||
|
||||
function ChooseVendor(sVendor) {
|
||||
const $cbs = $(`.OneVendorBlock[vendor='${sVendor}'] .ModelCheckBox`);
|
||||
const sel = $cbs.length && $cbs.not('.ModelCheckBoxSelected').length;
|
||||
|
||||
sel ? $cbs.addClass('ModelCheckBoxSelected')
|
||||
: $cbs.removeClass('ModelCheckBoxSelected');
|
||||
|
||||
$cbs.each((i, el)=>{SetModelSelect(sVendor, el.getAttribute('model'), sel)});
|
||||
}
|
||||
|
||||
function UpdateVendorCheckbox(sVendor) {
|
||||
const $vb = $(`.OneVendorBlock[vendor='${sVendor}']`);
|
||||
const $cbs = $vb.find(`.ModelCheckBox`);
|
||||
const $vcb = $vb.find(`.VendorCheckbox`);
|
||||
|
||||
const selCount = $cbs.filter('.ModelCheckBoxSelected').length;
|
||||
const allSel = selCount === $cbs.length && selCount > 0;
|
||||
const nonSel = selCount === 0;
|
||||
|
||||
$vcb.prop({checked: allSel , indeterminate: !allSel && !nonSel});
|
||||
|
||||
$vb.find(".modelCount").text(selCount + " / " + $cbs.length);
|
||||
}
|
||||
|
||||
|
||||
function GotoFilamentPage()
|
||||
{
|
||||
let nChoose=OnExitFilter();
|
||||
@@ -397,97 +17,3 @@ function GotoFilamentPage()
|
||||
if(nChoose>0)
|
||||
window.open('../22/index.html','_self');
|
||||
}
|
||||
|
||||
function OnExitFilter() {
|
||||
|
||||
let nTotal = 0;
|
||||
let ModelAll = {};
|
||||
for (vendor in ModelNozzleSelected) {
|
||||
for (model in ModelNozzleSelected[vendor]) {
|
||||
if (!ModelNozzleSelected[vendor][model])
|
||||
continue;
|
||||
|
||||
if (!ModelAll.hasOwnProperty(model)) {
|
||||
//alert("ADD: "+strModel);
|
||||
|
||||
ModelAll[model] = {};
|
||||
|
||||
ModelAll[model]["model"] = model;
|
||||
}
|
||||
|
||||
nTotal++;
|
||||
}
|
||||
}
|
||||
|
||||
var tSend = {};
|
||||
tSend['sequence_id'] = Math.round(new Date() / 1000);
|
||||
tSend['command'] = "save_userguide_models";
|
||||
tSend['data'] = ModelAll;
|
||||
|
||||
SendWXMessage(JSON.stringify(tSend));
|
||||
|
||||
return nTotal;
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
function OnExit()
|
||||
{
|
||||
let ModelAll={};
|
||||
|
||||
let ModelSelect=$(".ModelCheckBoxSelected");
|
||||
let nTotal=ModelSelect.length;
|
||||
|
||||
if( nTotal==0 )
|
||||
{
|
||||
ShowNotice(1);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
for(let n=0;n<nTotal;n++)
|
||||
{
|
||||
let OneItem=ModelSelect[n];
|
||||
|
||||
let strModel=OneItem.getAttribute("model");
|
||||
|
||||
//alert(strModel+strVendor+strNozzel);
|
||||
|
||||
if(!ModelAll.hasOwnProperty(strModel))
|
||||
{
|
||||
//alert("ADD: "+strModel);
|
||||
|
||||
ModelAll[strModel]={};
|
||||
|
||||
ModelAll[strModel]["model"]=strModel;
|
||||
}
|
||||
}
|
||||
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="save_userguide_models";
|
||||
tSend['data']=ModelAll;
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
|
||||
return nTotal;
|
||||
}
|
||||
|
||||
|
||||
function ShowNotice( nShow )
|
||||
{
|
||||
if(nShow==0)
|
||||
{
|
||||
$("#NoticeMask").hide();
|
||||
$("#NoticeBody").hide();
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#NoticeMask").show();
|
||||
$("#NoticeBody").show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
441
resources/web/guide/21/common.css
Normal file
441
resources/web/guide/21/common.css
Normal file
@@ -0,0 +1,441 @@
|
||||
#Content {
|
||||
overflow-y:auto;
|
||||
padding: 0 10px 0 20px; /* ORCA Specify & Reduce horizontal paddings to fit 4 items per row */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* VENDOR BLOCK */
|
||||
.OneVendorBlock {
|
||||
position: relative;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.OneVendorBlock:last-of-type {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.BlockBanner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0px;
|
||||
border-bottom: 2px solid var(--main-color);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
background-color: var(--bg-color-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.BannerBtns {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-right: 5px; /* ORCA align buttons with end of horizontal separator/line */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.BlockBanner a {
|
||||
line-height: 30px;
|
||||
height: 30px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
padding: 0px 10px;
|
||||
color: var(--fg-color-text);
|
||||
}
|
||||
|
||||
.BlockBanner .modelCount {
|
||||
margin: 0 15px 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
height: 15px;
|
||||
color: var(--fg-color-label);
|
||||
}
|
||||
|
||||
.VendorCheckbox {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.PrinterArea {
|
||||
padding: 7px 0px; /* ORCA Reduce horizontal paddings to fit 4 items per row */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
/* PRINTER BLOCK */
|
||||
.PrinterBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap:10px;
|
||||
padding: 15px 10px 10px 10px;
|
||||
background-color: var(--bg-color-secondary);
|
||||
position: relative;
|
||||
border: 1px solid transparent
|
||||
}
|
||||
|
||||
.PrinterBlock:hover {
|
||||
background-color: var(--focus-bg-item);
|
||||
border-color:var(--main-color);
|
||||
}
|
||||
|
||||
.PImg {
|
||||
width:120px; /* ORCA use covers as 120x120px but use source file as 240x240 for better quality on hidpi */
|
||||
height:120px; /* ORCA fit image to fill frame */
|
||||
}
|
||||
|
||||
.PrinterInfo,
|
||||
.PrinterInfoMark {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
opacity: 0;
|
||||
border-radius: 11px;
|
||||
line-height: 19px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.PrinterInfo {
|
||||
--card-animation-delay: .8s; /* open info with delay on list / compact view to prevent them appear while mouse movements */
|
||||
left: 4px;
|
||||
width: auto;
|
||||
z-index: 9998;
|
||||
height: fit-content;
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-color);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
color: var(--fg-color-text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#Content[layout="large-cover"] .PrinterInfo {
|
||||
--card-animation-delay: .3s;
|
||||
}
|
||||
|
||||
.PrinterInfo .title {font-weight: 700}
|
||||
.PrinterInfo .value {font-weight: 400}
|
||||
|
||||
.PrinterInfoMark:hover + .PrinterInfo {
|
||||
animation: infoCard 0s forwards var(--card-animation-delay);
|
||||
}
|
||||
|
||||
@keyframes infoCard {100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
||||
}}
|
||||
|
||||
.PrinterInfoMark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--main-color);
|
||||
border: 1px solid var(--main-color);
|
||||
z-index: 9999;
|
||||
color: #FFF;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.PrinterBlock:hover .PrinterInfoMark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.PrinterBlock:hover .PrinterInfoMark:hover {
|
||||
background: var(--main-color-hover);
|
||||
}
|
||||
|
||||
.ModelCheckBox {
|
||||
position: absolute;
|
||||
height: 6px;
|
||||
bottom: 0;
|
||||
left: 10%;
|
||||
width: 80%;
|
||||
background: var(--button-bg-hover)
|
||||
}
|
||||
|
||||
.ModelCheckBox.ModelCheckBoxSelected {
|
||||
background: var(--main-color-fixed)
|
||||
}
|
||||
|
||||
img.ModelThumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.PName {
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* ORCA */
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
color: var(--fg-color-text);
|
||||
}
|
||||
|
||||
/* LAYOUT SELECTOR */
|
||||
.LayoutSelector {
|
||||
position: absolute;
|
||||
right:21px;
|
||||
top:14px;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabGroup {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-color-alt);
|
||||
}
|
||||
|
||||
.LayoutSelector .icon16 {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabButton {
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabButton.selected {background: var(--main-color)}
|
||||
.LayoutSelector .TabButton.selected:hover {background: var(--main-color-hover)}
|
||||
.LayoutSelector .TabButton.selected .icon16 {background: #FFF}
|
||||
|
||||
.LayoutSelector .TabButton:nth-of-type(1) .icon16 {--icon-url: var(--icon-layout-list)}
|
||||
.LayoutSelector .TabButton:nth-of-type(2) .icon16 {--icon-url: var(--icon-layout-compact)}
|
||||
.LayoutSelector .TabButton:nth-of-type(3) .icon16 {--icon-url: var(--icon-layout-cover)}
|
||||
|
||||
/* LAYOUT */
|
||||
#Content[layout="compact-list"] .PrinterArea {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
#Content[layout="compact-list"] .PImg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#Content[layout="compact-list"] .OneVendorBlock {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#Content[layout="compact-cover"] .PrinterArea {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
#Content[layout="compact-cover"] .PImg {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .PName {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .PrinterBlock {
|
||||
flex-direction: row;
|
||||
padding: 5px 5px 5px 18px;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .ModelCheckBox {
|
||||
width: 6px;
|
||||
height: 80%;
|
||||
left:0;
|
||||
top:10%
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .OneVendorBlock:last-of-type {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
/* SEARCH */
|
||||
.search {
|
||||
position: absolute;
|
||||
left:66px;
|
||||
top: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
z-index: 99;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search:focus-within,
|
||||
.search[hasvalue="1"] {
|
||||
width: calc(100% - 194px);
|
||||
}
|
||||
|
||||
.searchTerm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px 5px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--button-bg-normal);
|
||||
border: 1px solid var(--button-bg-normal);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.searchTerm {
|
||||
transition: background-color .2s
|
||||
}
|
||||
}
|
||||
|
||||
.searchTerm,
|
||||
.search-placeholder {
|
||||
line-height: 24px; /* ORCA center text vertically */
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search:focus-within .searchTerm,
|
||||
.search[hasvalue="1"] .searchTerm {
|
||||
padding-left:33px;
|
||||
background: var(--bg-color);
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.search[hasvalue="1"]:not(:focus-within, :hover) .searchTerm {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.search:not(:focus-within, [hasvalue="1"]) .searchTerm {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search:not(:focus-within, [hasvalue="1"]) .searchTerm:hover {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: var(--fg-color-disabled);
|
||||
left: 33px;
|
||||
}
|
||||
|
||||
.searchTerm:not(:placeholder-shown) + .search-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.search-icon,
|
||||
.search-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
left: 9px;
|
||||
--icon-url: var(--icon-search);
|
||||
}
|
||||
|
||||
/* SIDEBAR */
|
||||
.SidebarBtn {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 14px;
|
||||
padding: 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.SidebarBtn .icon16 {
|
||||
--icon-url: var(--icon-sidebar);
|
||||
}
|
||||
|
||||
#SidebarContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -240px;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#SidebarContainer {
|
||||
transition: background-color .2s, left .2s
|
||||
}
|
||||
}
|
||||
|
||||
#SidebarContainer[open="1"] {
|
||||
left: 0px;
|
||||
pointer-events: all;
|
||||
background: rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
#Sidebar {
|
||||
flex: 0 0 220px;
|
||||
background: var(--bg-color);
|
||||
box-shadow: 5px 0 20px rgba(0,0,0,.2);
|
||||
padding: 15px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#Sidebar .title {
|
||||
font-size: 17px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
padding: 0 0 5px 20px;
|
||||
}
|
||||
|
||||
#Sidebar .SidebarItem {
|
||||
width: 100%;
|
||||
padding: 2px 10px 2px 20px;
|
||||
color:var(--fg-color-text);
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#Sidebar .SidebarItem:hover {
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
#SidebarContainer .back {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* NOTICE POPUP */
|
||||
#NoticeMask {
|
||||
background-color: #000;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.05;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#NoticeBody {
|
||||
display: none;
|
||||
width: 400px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background-color: inherit;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 200px;
|
||||
margin-left: -200px;
|
||||
}
|
||||
|
||||
#NoticeBar {
|
||||
background-color:#00f0d8;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#NoticeContent {
|
||||
padding: 4mm 10mm;
|
||||
}
|
||||
|
||||
#NoticeBtns {
|
||||
margin-top: 4mm;
|
||||
display: flex;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
632
resources/web/guide/21/common.js
Normal file
632
resources/web/guide/21/common.js
Normal file
@@ -0,0 +1,632 @@
|
||||
var pModel = {};
|
||||
var ModelNozzleSelected = {};
|
||||
let SearchBox;
|
||||
let $content;
|
||||
|
||||
function InitGlobalVariables()
|
||||
{
|
||||
SearchBox = document.querySelector('.searchTerm');
|
||||
$content = $('#Content');
|
||||
}
|
||||
|
||||
function RequestProfile()
|
||||
{
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="request_userguide_profile";
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
function HandleStudio( pVal )
|
||||
{
|
||||
// alert(strInput);
|
||||
// alert(JSON.stringify(strInput));
|
||||
//
|
||||
// let pVal=IsJson(strInput);
|
||||
// if(pVal==null)
|
||||
// {
|
||||
// alert("Msg Format Error is not Json");
|
||||
// return;
|
||||
// }
|
||||
|
||||
let strCmd=pVal['command'];
|
||||
//alert(strCmd);
|
||||
|
||||
if(strCmd=='response_userguide_profile')
|
||||
{
|
||||
HandleModelList(pVal['response']);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleModelList( pVal )
|
||||
{
|
||||
if( !pVal.hasOwnProperty("model") )
|
||||
return;
|
||||
|
||||
pModel=pVal['model'];
|
||||
|
||||
// ORCA ensure list correctly ordered
|
||||
pModel = pModel.sort((a, b)=>(a["vendor"].localeCompare(b["vendor"])))
|
||||
pModel = [ // move custom printers to top
|
||||
...pModel.filter(i=>i.vendor === "Custom"),
|
||||
...pModel.filter(i=>i.vendor !== "Custom")
|
||||
];
|
||||
|
||||
let nTotal=pModel.length;
|
||||
let ModelHtml={};
|
||||
for(let n=0;n<nTotal;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
|
||||
let strVendor=OneModel['vendor'];
|
||||
|
||||
//Add Vendor Html Node
|
||||
if($(".OneVendorBlock[vendor='"+strVendor+"']").length==0)
|
||||
{
|
||||
let HtmlNewVendor = CreateVendorBlock(strVendor);
|
||||
$('#Content').append(HtmlNewVendor);
|
||||
}
|
||||
|
||||
let ModelName=OneModel['model'];
|
||||
|
||||
//Collect Html Node Nozzel Html
|
||||
if( !ModelHtml.hasOwnProperty(strVendor))
|
||||
ModelHtml[strVendor]='';
|
||||
|
||||
ModelHtml[strVendor] += CreatePrinterBlock(OneModel); // ORCA
|
||||
}
|
||||
|
||||
//Update Nozzel Html Append
|
||||
for( let key in ModelHtml )
|
||||
{
|
||||
$(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").append( ModelHtml[key] );
|
||||
}
|
||||
|
||||
//Update Checkbox
|
||||
for(let m=0;m<nTotal;m++)
|
||||
{
|
||||
let OneModel=pModel[m];
|
||||
|
||||
let SelectList=OneModel['nozzle_selected'];
|
||||
if(SelectList!='') {
|
||||
ChooseModel(OneModel['vendor'], OneModel['model']);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSidebarVendors();
|
||||
|
||||
// let AlreadySelect=$(".ModelCheckBoxSelected");
|
||||
// let nSelect=AlreadySelect.length;
|
||||
// if(nSelect==0)
|
||||
// {
|
||||
// $("div.OneVendorBlock[vendor='"+BBL+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
// }
|
||||
|
||||
TranslatePage();
|
||||
}
|
||||
|
||||
function SetModelSelect(vendor, model, checked) {
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor) && !checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor) && checked) {
|
||||
ModelNozzleSelected[vendor] = {};
|
||||
}
|
||||
|
||||
let oVendor = ModelNozzleSelected[vendor];
|
||||
if (oVendor.hasOwnProperty(model) || checked) {
|
||||
oVendor[model] = checked;
|
||||
}
|
||||
|
||||
UpdateVendorCheckbox(vendor)
|
||||
}
|
||||
|
||||
function GetModelSelect(vendor, model) {
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let oVendor = ModelNozzleSelected[vendor];
|
||||
if (!oVendor.hasOwnProperty(model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return oVendor[model];
|
||||
}
|
||||
|
||||
function ChooseModel(vendor, ModelName)
|
||||
{
|
||||
let ChooseItem=$(".ModelCheckBox[vendor='"+vendor+"'][model='"+ModelName+"']");
|
||||
|
||||
if(ChooseItem.length > 0) {
|
||||
if( $(ChooseItem).hasClass('ModelCheckBoxSelected') )
|
||||
$(ChooseItem).removeClass('ModelCheckBoxSelected');
|
||||
else
|
||||
$(ChooseItem).addClass('ModelCheckBoxSelected');
|
||||
|
||||
SetModelSelect(vendor, ModelName, $(ChooseItem).hasClass('ModelCheckBoxSelected'));
|
||||
}
|
||||
}
|
||||
|
||||
function FilterModelList(keyword) {
|
||||
|
||||
//Save checkbox state
|
||||
let ModelSelect = $('.ModelCheckBox');
|
||||
for (let n = 0; n < ModelSelect.length; n++) {
|
||||
let OneItem = ModelSelect[n];
|
||||
|
||||
let strModel = OneItem.getAttribute("model");
|
||||
|
||||
let strVendor = OneItem.getAttribute("vendor");
|
||||
|
||||
SetModelSelect(strVendor, strModel, $(OneItem).hasClass('ModelCheckBoxSelected'));
|
||||
}
|
||||
|
||||
$('.search')[0].setAttribute("hasvalue", keyword ? "1" : "0")
|
||||
|
||||
let nTotal = pModel.length;
|
||||
let ModelHtml = {};
|
||||
let kwSplit = keyword.toLowerCase().match(/\S+/g) || [];
|
||||
|
||||
$('#Content').empty();
|
||||
for (let n = 0; n < nTotal; n++) {
|
||||
let OneModel = pModel[n];
|
||||
|
||||
let strVendor = OneModel['vendor'];
|
||||
let search = (OneModel['name'] + '\0' + strVendor).toLowerCase();
|
||||
|
||||
if (!kwSplit.every(s => search.includes(s)))
|
||||
continue;
|
||||
|
||||
//Add Vendor Html Node
|
||||
if ($(".OneVendorBlock[vendor='" + strVendor + "']").length == 0) {
|
||||
let HtmlNewVendor = CreateVendorBlock(strVendor);
|
||||
$('#Content').append(HtmlNewVendor);
|
||||
}
|
||||
|
||||
//Collect Html Node Nozzel Html
|
||||
if (!ModelHtml.hasOwnProperty(strVendor))
|
||||
ModelHtml[strVendor] = '';
|
||||
|
||||
ModelHtml[strVendor] += CreatePrinterBlock(OneModel); // ORCA
|
||||
}
|
||||
|
||||
//Update Nozzel Html Append
|
||||
for (let key in ModelHtml) {
|
||||
let obj = $(".OneVendorBlock[vendor='" + key + "'] .PrinterArea");
|
||||
obj.empty();
|
||||
obj.append(ModelHtml[key]);
|
||||
}
|
||||
|
||||
//Update Checkbox
|
||||
ModelSelect = $('.ModelCheckBox');
|
||||
for (let n = 0; n < ModelSelect.length; n++) {
|
||||
let OneItem = ModelSelect[n];
|
||||
|
||||
let strModel = OneItem.getAttribute("model");
|
||||
let strVendor = OneItem.getAttribute("vendor");
|
||||
|
||||
let checked = GetModelSelect(strVendor, strModel);
|
||||
|
||||
if (checked)
|
||||
$(OneItem).addClass('ModelCheckBoxSelected');
|
||||
else
|
||||
$(OneItem).removeClass('ModelCheckBoxSelected');
|
||||
}
|
||||
|
||||
UpdateSidebarVendors();
|
||||
|
||||
$content.css("padding-right", $content[0].scrollHeight > $content[0].clientHeight ? "10px" : "20px");
|
||||
|
||||
// let AlreadySelect=$(".ModelCheckBoxSelected");
|
||||
// let nSelect=AlreadySelect.length;
|
||||
// if(nSelect==0)
|
||||
// {
|
||||
// $("div.OneVendorBlock[vendor='"+BBL+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
// }
|
||||
|
||||
TranslatePage();
|
||||
}
|
||||
|
||||
function textInput(obj) {
|
||||
FilterModelList(obj.value);
|
||||
}
|
||||
|
||||
function CreateVendorBlock(vendorName)
|
||||
{
|
||||
let alt = vendorName;
|
||||
if( alt == "BBL" )
|
||||
alt = "Bambu Lab";
|
||||
if( alt == "Custom")
|
||||
alt = "Custom Printer";
|
||||
if( alt == "Other")
|
||||
alt = "Orca colosseum";
|
||||
|
||||
return '<div class="OneVendorBlock" Vendor="' + vendorName + '">' +
|
||||
' <div class="BlockBanner">' +
|
||||
' <a>' + alt + '</a>' +
|
||||
' <div class="BannerBtns" onClick="ChooseVendor('+"\'"+vendorName+"\'"+')">'+
|
||||
' <div class="modelCount"></div>' +
|
||||
' <input type="checkbox" class="VendorCheckbox"/>'+
|
||||
' </div>'+
|
||||
' </div>' +
|
||||
' <div class="PrinterArea"> ' +
|
||||
' </div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function CreatePrinterBlock(OneModel)
|
||||
{
|
||||
let vendor = OneModel['vendor']
|
||||
let vendorName = vendor=="BBL" ? "Bambu Lab" : vendor=="Custom" ? "Generic Printer" : vendor;
|
||||
let modelName = OneModel['name'];
|
||||
|
||||
// Most of it unneeded. this can be applied in profiles
|
||||
if( vendor=="Custom")
|
||||
modelName = modelName.split(" ")[1];
|
||||
// these uses different case in name; seckit, ratrig, blocks
|
||||
else if (modelName.toLowerCase().startsWith(vendorName.toLowerCase()))
|
||||
modelName = modelName.slice(vendorName.length);
|
||||
// these not matches. have to fix in profiles to reduce conditions in here;
|
||||
else if (vendor == "MagicMaker" && modelName.startsWith("MM"))
|
||||
modelName = modelName.slice(("MM").length);
|
||||
else if (vendor == "OrcaArena")
|
||||
modelName = modelName.slice(("Orca Arena").length);
|
||||
else if (vendor == "RolohaunDesign" && modelName.startsWith("Rolohaun"))
|
||||
modelName = modelName.slice(("Rolohaun").length);
|
||||
|
||||
return '<div class="PrinterBlock" onClick="ChooseModel(\''+vendor+'\',\''+OneModel['model']+'\')">'+
|
||||
' <div class="PImg">'+
|
||||
' <img class="ModelThumbnail" src="' + OneModel['cover'] + '" />'+
|
||||
' </div>'+
|
||||
' <div class="PrinterInfoMark">?</div>'+
|
||||
' <div class="PrinterInfo">'+
|
||||
' <div class="title trans">Nozzle</div>'+
|
||||
' <div class="value">' + OneModel['nozzle_diameter'].replaceAll(";", " · ") + '</div>'+
|
||||
' </div>'+
|
||||
' <div style="display: flex;">'+
|
||||
' <div class="ModelCheckBox" vendor="' +vendor+ '" model="'+OneModel['model']+'"></div>'+
|
||||
' <div class="PName">'+ modelName +'</div>'+ // ><p>'+ vendorName +'</p>
|
||||
' </div>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function scrollToVendor(vendor) {
|
||||
const el = $(".OneVendorBlock[vendor='"+vendor+"']")[0];
|
||||
if (el){
|
||||
document.getElementById('SidebarContainer').setAttribute('open', '0');
|
||||
document.getElementById('Content').scrollTo({top: el.offsetTop, behavior: "smooth"});
|
||||
}
|
||||
}
|
||||
|
||||
function UpdateSidebarVendors()
|
||||
{
|
||||
let SidebarHTML = "";
|
||||
$(`.OneVendorBlock`).each((i, el)=>{
|
||||
UpdateVendorCheckbox(el.getAttribute("vendor"));
|
||||
SidebarHTML +=`<div class="SidebarItem" onclick="scrollToVendor(this.textContent)">${el.getAttribute('vendor')}</div>`;
|
||||
});
|
||||
$('#SidebarVendors').html(SidebarHTML)
|
||||
}
|
||||
|
||||
function ChooseVendor(sVendor) { // automatically selects / unselects all
|
||||
const $cbs = $(`.OneVendorBlock[vendor='${sVendor}'] .ModelCheckBox`);
|
||||
const sel = $cbs.length && $cbs.not('.ModelCheckBoxSelected').length;
|
||||
|
||||
sel ? $cbs.addClass('ModelCheckBoxSelected')
|
||||
: $cbs.removeClass('ModelCheckBoxSelected');
|
||||
|
||||
$cbs.each((i, el)=>{SetModelSelect(sVendor, el.getAttribute('model'), sel)});
|
||||
}
|
||||
|
||||
function UpdateVendorCheckbox(sVendor) {
|
||||
const $vb = $(`.OneVendorBlock[vendor='${sVendor}']`);
|
||||
const $cbs = $vb.find(`.ModelCheckBox`);
|
||||
const $vcb = $vb.find(`.VendorCheckbox`);
|
||||
|
||||
const selCount = $cbs.filter('.ModelCheckBoxSelected').length;
|
||||
const allSel = selCount === $cbs.length && selCount > 0;
|
||||
const nonSel = selCount === 0;
|
||||
|
||||
$vcb.prop({checked: allSel , indeterminate: !allSel && !nonSel});
|
||||
|
||||
$vb.find(".modelCount").text(selCount + " / " + $cbs.length);
|
||||
}
|
||||
|
||||
function OnExit()
|
||||
{
|
||||
let ModelAll={};
|
||||
|
||||
let ModelSelect=$(".ModelCheckBoxSelected");
|
||||
let nTotal=ModelSelect.length;
|
||||
|
||||
if( nTotal==0 ) {
|
||||
ShowNotice(1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for(let n=0;n<nTotal;n++)
|
||||
{
|
||||
let OneItem=ModelSelect[n];
|
||||
|
||||
let strModel=OneItem.getAttribute("model");
|
||||
|
||||
//alert(strModel+strVendor+strNozzel);
|
||||
|
||||
if(!ModelAll.hasOwnProperty(strModel))
|
||||
{
|
||||
//alert("ADD: "+strModel);
|
||||
|
||||
ModelAll[strModel]={};
|
||||
|
||||
ModelAll[strModel]["model"]=strModel;
|
||||
}
|
||||
}
|
||||
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="save_userguide_models";
|
||||
tSend['data']=ModelAll;
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
|
||||
return nTotal;
|
||||
}
|
||||
|
||||
function OnExitFilter() {
|
||||
let nTotal = 0;
|
||||
let ModelAll = {};
|
||||
for (let vendor in ModelNozzleSelected) {
|
||||
for (let model in ModelNozzleSelected[vendor]) {
|
||||
if (!ModelNozzleSelected[vendor][model])
|
||||
continue;
|
||||
|
||||
if (!ModelAll.hasOwnProperty(model)) {
|
||||
//alert("ADD: "+strModel);
|
||||
|
||||
ModelAll[model] = {};
|
||||
|
||||
ModelAll[model]["model"] = model;
|
||||
}
|
||||
|
||||
nTotal++;
|
||||
}
|
||||
}
|
||||
|
||||
var tSend = {};
|
||||
tSend['sequence_id'] = Math.round(new Date() / 1000);
|
||||
tSend['command'] = "save_userguide_models";
|
||||
tSend['data'] = ModelAll;
|
||||
|
||||
SendWXMessage(JSON.stringify(tSend));
|
||||
|
||||
return nTotal;
|
||||
}
|
||||
|
||||
function ShowNotice( nShow )
|
||||
{
|
||||
if(nShow==0) {
|
||||
$("#NoticeMask").hide();
|
||||
$("#NoticeBody").hide();
|
||||
}
|
||||
else {
|
||||
$("#NoticeMask").show();
|
||||
$("#NoticeBody").show();
|
||||
}
|
||||
}
|
||||
|
||||
// SNAPPY SCROLLING WITHOUT LAGS
|
||||
const SNAP_DELAY = 600;
|
||||
const SNAP_DURATION = 200;
|
||||
const SNAP_CORR = 8; // error correction / tolerance
|
||||
|
||||
let scrollTimer = null;
|
||||
let lastScrollTop = 0;
|
||||
let scrollDir = 'down';
|
||||
let isSnapping = false;
|
||||
let snapRafId = null;
|
||||
let lastSnapTarget = null;
|
||||
let waitingForUserScroll = false;
|
||||
|
||||
function findSnap(cur, dir) {
|
||||
if (lastSnapTarget !== null && Math.abs(cur - lastSnapTarget) < SNAP_CORR) return null;
|
||||
|
||||
const savedScroll = cur;
|
||||
|
||||
$content[0].scrollTop = 0; // Temporarily scroll to 0 so getBoundingClientRect can get absolute positions
|
||||
|
||||
let bcTop = el=>(el.getBoundingClientRect().top);
|
||||
|
||||
const contentTop = bcTop($content[0]);
|
||||
const bannerH = ($content.find('.BlockBanner')[0] || {}).offsetHeight || 0;
|
||||
|
||||
const firstCard = $content.find('.PrinterBlock')[0];
|
||||
const firstArea = $content.find('.PrinterArea')[0];
|
||||
const cardGap = (firstCard && firstArea) ? (bcTop(firstCard) - bcTop(firstArea)) : 0;
|
||||
|
||||
const candidates = $content.find('.BlockBanner, .PrinterBlock').get();
|
||||
if (dir === 'up') candidates.reverse();
|
||||
|
||||
let result = lastSeen = null;
|
||||
|
||||
for (const el of candidates) {
|
||||
const snapTo = Math.round(
|
||||
el.classList.contains('BlockBanner')
|
||||
? (bcTop(el.closest('.OneVendorBlock')) - contentTop)
|
||||
: Math.max(0, bcTop(el) - contentTop - bannerH - cardGap)
|
||||
);
|
||||
if (snapTo != lastSeen){
|
||||
lastSeen = snapTo;
|
||||
if (dir === 'down' && snapTo > cur + SNAP_CORR) { result = snapTo; break; }
|
||||
if (dir === 'up' && snapTo < cur - SNAP_CORR) { result = snapTo; break; }
|
||||
}
|
||||
}
|
||||
|
||||
$content[0].scrollTop = savedScroll; // Restore scroll position
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function smoothScrollTo(target) {
|
||||
if (snapRafId) {
|
||||
cancelAnimationFrame(snapRafId);
|
||||
snapRafId = null;
|
||||
}
|
||||
|
||||
const el = $content[0];
|
||||
const from = el.scrollTop;
|
||||
const dist = target - from;
|
||||
const t0 = performance.now();
|
||||
const ease = t => t < 0.5 ? 2*t*t : -1 + (4 - 2*t)*t;
|
||||
|
||||
function onDone() {
|
||||
el.scrollTop = target;
|
||||
lastScrollTop = lastSnapTarget = target;
|
||||
waitingForUserScroll = true;
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = null;
|
||||
snapRafId = null;
|
||||
isSnapping = false;
|
||||
}
|
||||
|
||||
if (Math.abs(dist) < 2)
|
||||
return onDone();
|
||||
|
||||
snapRafId = requestAnimationFrame(function step(now) {
|
||||
const p = Math.min((now - t0) / SNAP_DURATION, 1);
|
||||
el.scrollTop = from + dist * ease(p);
|
||||
if (p < 1)
|
||||
snapRafId = requestAnimationFrame(step);
|
||||
else
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
function armSnap() {
|
||||
waitingForUserScroll = false;
|
||||
lastSnapTarget = null;
|
||||
}
|
||||
|
||||
function initScrollEvents() {
|
||||
$content.on('scroll', function() {
|
||||
if (isSnapping) return;
|
||||
|
||||
if (this.scrollTop > lastScrollTop + 1) scrollDir = 'down';
|
||||
else if (this.scrollTop < lastScrollTop - 1) scrollDir = 'up';
|
||||
|
||||
lastScrollTop = this.scrollTop;
|
||||
|
||||
if (waitingForUserScroll) return;
|
||||
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(()=>{
|
||||
if (isSnapping) return;
|
||||
|
||||
const target = findSnap($content[0].scrollTop, scrollDir);
|
||||
if (target){
|
||||
isSnapping = true;
|
||||
smoothScrollTo(target);
|
||||
}
|
||||
}, SNAP_DELAY);
|
||||
});
|
||||
|
||||
let touchY = 0;
|
||||
$content[0].addEventListener('touchstart', e => {
|
||||
touchY = e.touches[0].clientY;
|
||||
armSnap();
|
||||
}, { passive: true });
|
||||
|
||||
$content[0].addEventListener('touchmove', e => {
|
||||
const dy = touchY - e.touches[0].clientY;
|
||||
if (Math.abs(dy) > 3)
|
||||
scrollDir = dy > 0 ? 'down' : 'up';
|
||||
}, { passive: true });
|
||||
|
||||
// Re-arm snap system on user scroll
|
||||
$content[0].addEventListener('wheel', armSnap, { passive: true });
|
||||
|
||||
// Re-arm on after scrollbar usage
|
||||
$content[0].addEventListener('pointerdown', e => {
|
||||
if (e.target === $content[0])
|
||||
armSnap();
|
||||
});
|
||||
|
||||
// Re-arm on keyboard scroll or focus changes
|
||||
document.addEventListener('keydown', e => {
|
||||
if (document.activeElement != SearchBox){
|
||||
let scrollKeys = ['ArrowUp','ArrowDown','PageUp','PageDown',' '];
|
||||
let hasFocus = $content[0].contains(document.activeElement);
|
||||
if(scrollKeys.includes(e.key) || (hasFocus && e.which == 9))
|
||||
armSnap();
|
||||
}
|
||||
});
|
||||
|
||||
// ORCA unfocus search bar while scrolling and its content empty
|
||||
$content[0].addEventListener("scroll", () => {
|
||||
if (document.activeElement === SearchBox && SearchBox.value == "")
|
||||
SearchBox.blur();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initScrollEvents);
|
||||
|
||||
// LAYOUT SELECTOR
|
||||
function LayoutMode(value) {
|
||||
let LayoutSelector = document.querySelector('.LayoutSelector > .TabGroup');
|
||||
let LayoutBtns = Array.from(LayoutSelector.children);
|
||||
let LayoutTypes = ["compact-list","compact-cover","large-cover"];
|
||||
|
||||
if($content[0].getAttribute("layout") === value)
|
||||
return;
|
||||
|
||||
// find current visible vendor and scroll to it after layout change
|
||||
let target = null;
|
||||
for (const el of $content.find('.OneVendorBlock')) {
|
||||
if (el.getBoundingClientRect().bottom - $content[0].getBoundingClientRect().top >= -1) {
|
||||
target = el.getAttribute("vendor");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LayoutBtns.forEach(el => el.classList.remove('selected'));
|
||||
LayoutBtns[LayoutTypes.indexOf(value)].classList.add('selected');
|
||||
$content[0].setAttribute("layout", value);
|
||||
|
||||
if (target) scrollToVendor(target);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => LayoutMode("large-cover"));
|
||||
|
||||
// KEY EVENTS
|
||||
function initKeyEvents(closeOnESC) {
|
||||
document.onkeydown = function (event) {
|
||||
var e = event || window.event || arguments.callee.caller.arguments[0];
|
||||
|
||||
let sidebar = document.getElementById('SidebarContainer');
|
||||
|
||||
if (e.keyCode == 27){
|
||||
if(sidebar.getAttribute('open') == "1") { // prefer to close sidebar first if its open
|
||||
sidebar.setAttribute('open', '0');
|
||||
}
|
||||
else if (closeOnESC){
|
||||
ClosePage();
|
||||
}
|
||||
}
|
||||
|
||||
// ORCA focus search bar on key input
|
||||
// SearchBox not in focus && writable character && non modifier
|
||||
if (document.activeElement != SearchBox && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
SearchBox.focus();
|
||||
}
|
||||
|
||||
// Close sidebar on any key input
|
||||
sidebar.setAttribute('open', '0');
|
||||
|
||||
//if (window.event) {
|
||||
// try { e.keyCode = 0; } catch (e) { }
|
||||
// e.returnValue = true;
|
||||
//}
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>引导_P21</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" /> <!-- ORCA One for all-->
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../21/common.css" /> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<link rel="stylesheet" type="text/css" href="21.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/dark.css" />
|
||||
<script type="text/javascript" src="test.js"></script>
|
||||
@@ -14,7 +15,8 @@
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.js"></script>
|
||||
<script type="text/javascript" src="../js/common.js"></script>
|
||||
<script type="text/javascript" src="21.js"></script>
|
||||
<script type="text/javascript" src="../21/common.js"></script> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<script type="text/javascript" src="21.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
<div id="Title">
|
||||
@@ -49,93 +51,33 @@
|
||||
|
||||
<div id="Content" class="thin-scroll">
|
||||
|
||||
<!--<div class="OneVendorBlock" Vendor="BBL">
|
||||
<div class="BlockBanner">
|
||||
<a>BBL-3DP</a>
|
||||
<div class="BannerBtns">
|
||||
<div class="ButtonStyleConfirm ButtonTypeWindow trans" onClick="SelectPrinterAll('BBL')">所有</div>
|
||||
<div class="ButtonStyleRegular ButtonTypeWindow trans" onClick="SelectPrinterNone('BBL')">无</div>
|
||||
<!-- EXAMPLE GENERATED CODE BLOCK
|
||||
<div class="OneVendorBlock" Vendor="VendorName">
|
||||
<div class="BlockBanner">
|
||||
<a>VendorName</a>
|
||||
<div class="BannerBtns" onClick="ChooseVendor('VendorName')" >
|
||||
<div class="modelCount"></div>
|
||||
<input type="checkbox" class="VendorCheckbox" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="PrinterArea">
|
||||
<div class="PrinterBlock" onClick="ChooseModel('VendorName','ModelName')" >
|
||||
<div class="PImg" >
|
||||
<img class="ModelThumbnail" src="CoverPath" />
|
||||
</div>
|
||||
<div class="PrinterInfoMark">?</div>
|
||||
<div class="PrinterInfo">
|
||||
<div class="title trans">Nozzle</div>
|
||||
<div class="value">nozzleInfo</div>
|
||||
</div>
|
||||
<div style="display: flex;">
|
||||
<div class="ModelCheckBox" vendor="' +vendor+ '" model="'+OneModel['model']+'"></div>
|
||||
<div class="PName">modelName</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="PrinterArea">
|
||||
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg">
|
||||
<img class="ModelThumbnail" src="p2.jpg" />
|
||||
<div class="ModelCheckBox ModelCheckBoxSelected" model="BBL-3DP-V5NORMAL" onClick="ChooseModel('BBL-3DP-V5NORMAL')"></div>
|
||||
</div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel TextS2"><input id="ZZ" type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.4" vendor="BBL" />0.4mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.1" vendor="BBL" />0.1mm nozzle</div>
|
||||
</div>
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg">
|
||||
<img class="ModelThumbnail" src="p2.jpg" />
|
||||
<div class="ModelCheckBox"></div>
|
||||
</div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.4" vendor="BBL" />0.4mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.2" vendor="BBL" />0.22mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.1" vendor="BBL" />0.1mm nozzle</div>
|
||||
</div>
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg">
|
||||
<img class="ModelThumbnail" src="p2.jpg" />
|
||||
<div class="ModelCheckBox"></div>
|
||||
</div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel TextS2"><input id="ZZ" type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.4" vendor="BBL" />0.4mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.1" vendor="BBL" />0.11mm nozzle</div>
|
||||
</div>
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg">
|
||||
<img class="ModelThumbnail" src="p2.jpg" />
|
||||
<div class="ModelCheckBox ModelCheckBoxSelected"></div>
|
||||
</div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.4" vendor="BBL" />0.4mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.2" vendor="BBL" />0.22mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.1" vendor="BBL" />0.1mm nozzle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="OneVendorBlock" Vendor="BAMBU">
|
||||
<div class="BlockBanner">
|
||||
<a>BAMBU-3DP</a>
|
||||
<div class="BannerBtns">
|
||||
<div class="SmallBtn_Green trans" onClick="SelectPrinterAll('BAMBU')">所有</div>
|
||||
<div class="SmallBtn trans" onClick="SelectPrinterNone('BAMBU')">无</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="PrinterArea">
|
||||
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg">
|
||||
<img class="ModelThumbnail" src="p2.jpg" />
|
||||
<div class="ModelCheckBox ModelCheckBoxSelected"></div>
|
||||
</div>
|
||||
<div class="PName TextS1">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.4" vendor="BAMBU" />0.4mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.1" vendor="BAMBU" />0.1mm nozzle</div>
|
||||
</div>
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg">
|
||||
<img class="ModelThumbnail" src="p2.jpg" />
|
||||
<div class="ModelCheckBox ModelCheckBoxSelected"></div>
|
||||
</div>
|
||||
<div class="PName TextS1">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.4" vendor="BAMBU" />0.4mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.2" vendor="BAMBU" />0.2mm nozzle</div>
|
||||
<div class="pNozzel TextS2"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.1" vendor="BAMBU" />0.1mm nozzle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>-->
|
||||
-->
|
||||
|
||||
</div>
|
||||
<div id="AcceptArea">
|
||||
@@ -153,212 +95,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
<script>
|
||||
const SearchBox = document.querySelector('.searchTerm');
|
||||
|
||||
document.onkeydown = function (event) {
|
||||
var e = event || window.event || arguments.callee.caller.arguments[0];
|
||||
|
||||
// ORCA focus search bar on key input
|
||||
// SearchBox not in focus && writable character && non modifier
|
||||
if (document.activeElement != SearchBox && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
SearchBox.focus();
|
||||
}
|
||||
|
||||
// Close sidebar
|
||||
document.getElementById('SidebarContainer').setAttribute('open', '0')
|
||||
|
||||
//if (window.event) {
|
||||
// try { e.keyCode = 0; } catch (e) { }
|
||||
// e.returnValue = true;
|
||||
//}
|
||||
};
|
||||
let pModel = {};
|
||||
let ModelNozzleSelected = {};
|
||||
function textInput(obj) {
|
||||
FilterModelList(obj.value);
|
||||
}
|
||||
|
||||
const $content = $('#Content');
|
||||
|
||||
// SNAPPY SCROLLING WITHOUT LAGS
|
||||
const SNAP_DELAY = 600;
|
||||
const SNAP_DURATION = 200;
|
||||
const SNAP_CORR = 8; // error correction / tolerance
|
||||
|
||||
let scrollTimer = null;
|
||||
let lastScrollTop = 0;
|
||||
let scrollDir = 'down';
|
||||
let isSnapping = false;
|
||||
let snapRafId = null;
|
||||
let lastSnapTarget = null;
|
||||
let waitingForUserScroll = false;
|
||||
|
||||
function findSnap(cur, dir) {
|
||||
if (lastSnapTarget !== null && Math.abs(cur - lastSnapTarget) < SNAP_CORR) return null;
|
||||
|
||||
const savedScroll = cur;
|
||||
|
||||
$content[0].scrollTop = 0; // Temporarily scroll to 0 so getBoundingClientRect can get absolute positions
|
||||
|
||||
let bcTop = el=>(el.getBoundingClientRect().top);
|
||||
|
||||
const contentTop = bcTop($content[0]);
|
||||
const bannerH = ($content.find('.BlockBanner')[0] || {}).offsetHeight || 0;
|
||||
|
||||
const firstCard = $content.find('.PrinterBlock')[0];
|
||||
const firstArea = $content.find('.PrinterArea')[0];
|
||||
const cardGap = (firstCard && firstArea) ? (bcTop(firstCard) - bcTop(firstArea)) : 0;
|
||||
|
||||
const candidates = $content.find('.BlockBanner, .PrinterBlock').get();
|
||||
if (dir === 'up') candidates.reverse();
|
||||
|
||||
let result = lastSeen = null;
|
||||
|
||||
for (const el of candidates) {
|
||||
const snapTo = Math.round(
|
||||
el.classList.contains('BlockBanner')
|
||||
? (bcTop(el.closest('.OneVendorBlock')) - contentTop)
|
||||
: Math.max(0, bcTop(el) - contentTop - bannerH - cardGap)
|
||||
);
|
||||
if (snapTo != lastSeen){
|
||||
lastSeen = snapTo;
|
||||
if (dir === 'down' && snapTo > cur + SNAP_CORR) { result = snapTo; break; }
|
||||
if (dir === 'up' && snapTo < cur - SNAP_CORR) { result = snapTo; break; }
|
||||
}
|
||||
}
|
||||
|
||||
$content[0].scrollTop = savedScroll; // Restore scroll position
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function smoothScrollTo(target) {
|
||||
if (snapRafId) {
|
||||
cancelAnimationFrame(snapRafId);
|
||||
snapRafId = null;
|
||||
}
|
||||
|
||||
const el = $content[0];
|
||||
const from = el.scrollTop;
|
||||
const dist = target - from;
|
||||
const t0 = performance.now();
|
||||
const ease = t => t < 0.5 ? 2*t*t : -1 + (4 - 2*t)*t;
|
||||
|
||||
function onDone() {
|
||||
el.scrollTop = target;
|
||||
lastScrollTop = lastSnapTarget = target;
|
||||
waitingForUserScroll = true;
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = null;
|
||||
snapRafId = null;
|
||||
isSnapping = false;
|
||||
}
|
||||
|
||||
if (Math.abs(dist) < 2)
|
||||
return onDone();
|
||||
|
||||
snapRafId = requestAnimationFrame(function step(now) {
|
||||
const p = Math.min((now - t0) / SNAP_DURATION, 1);
|
||||
el.scrollTop = from + dist * ease(p);
|
||||
if (p < 1)
|
||||
snapRafId = requestAnimationFrame(step);
|
||||
else
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
function armSnap() {
|
||||
waitingForUserScroll = false;
|
||||
lastSnapTarget = null;
|
||||
}
|
||||
|
||||
$content.on('scroll', function() {
|
||||
if (isSnapping) return;
|
||||
|
||||
if (this.scrollTop > lastScrollTop + 1) scrollDir = 'down';
|
||||
else if (this.scrollTop < lastScrollTop - 1) scrollDir = 'up';
|
||||
lastScrollTop = this.scrollTop;
|
||||
|
||||
if (waitingForUserScroll) return;
|
||||
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(()=>{
|
||||
if (isSnapping) return;
|
||||
|
||||
const target = findSnap($content[0].scrollTop, scrollDir);
|
||||
if (target){
|
||||
isSnapping = true;
|
||||
smoothScrollTo(target);
|
||||
}
|
||||
}, SNAP_DELAY);
|
||||
});
|
||||
|
||||
let touchY = 0;
|
||||
$content[0].addEventListener('touchstart', e => {
|
||||
touchY = e.touches[0].clientY;
|
||||
armSnap();
|
||||
}, { passive: true });
|
||||
|
||||
$content[0].addEventListener('touchmove', e => {
|
||||
const dy = touchY - e.touches[0].clientY;
|
||||
if (Math.abs(dy) > 3)
|
||||
scrollDir = dy > 0 ? 'down' : 'up';
|
||||
}, { passive: true });
|
||||
|
||||
// Re-arm snap system on user scroll
|
||||
$content[0].addEventListener('wheel', armSnap, { passive: true });
|
||||
|
||||
// Re-arm on after scrollbar usage
|
||||
$content[0].addEventListener('pointerdown', e => {
|
||||
if (e.target === $content[0])
|
||||
armSnap();
|
||||
});
|
||||
|
||||
// Re-arm on keyboard scroll or focus changes
|
||||
document.addEventListener('keydown', e => {
|
||||
if (document.activeElement != SearchBox){
|
||||
let scrollKeys = ['ArrowUp','ArrowDown','PageUp','PageDown',' '];
|
||||
let hasFocus = $content[0].contains(document.activeElement);
|
||||
if(scrollKeys.includes(e.key) || (hasFocus && e.which == 9))
|
||||
armSnap();
|
||||
}
|
||||
});
|
||||
|
||||
// ORCA unfocus search bar while scrolling and its content empty
|
||||
$content[0].addEventListener("scroll", () => {
|
||||
if (document.activeElement === SearchBox && SearchBox.value == "")
|
||||
SearchBox.blur();
|
||||
});
|
||||
|
||||
// LAYOUT SELECTOR
|
||||
const LayoutSelector = document.querySelector('.LayoutSelector > .TabGroup');
|
||||
const LayoutBtns = Array.from(LayoutSelector.children);
|
||||
const LayoutTypes = ["compact-list","compact-cover","large-cover"];
|
||||
|
||||
function LayoutMode(value) {
|
||||
if($content[0].getAttribute("layout") === value)
|
||||
return;
|
||||
|
||||
// find current visible vendor and scroll to it after layout change
|
||||
let target = null;
|
||||
for (const el of $content.find('.OneVendorBlock')) {
|
||||
if (el.getBoundingClientRect().bottom - $content[0].getBoundingClientRect().top >= -1) {
|
||||
target = el.getAttribute("vendor");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LayoutBtns.forEach(el => el.classList.remove('selected'));
|
||||
LayoutBtns[LayoutTypes.indexOf(value)].classList.add('selected');
|
||||
$content[0].setAttribute("layout", value);
|
||||
|
||||
if (target) scrollToVendor(target);
|
||||
}
|
||||
|
||||
LayoutMode("large-cover");
|
||||
initKeyEvents(false); // dont close on ESC
|
||||
InitGlobalVariables();
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -1,269 +1,7 @@
|
||||
|
||||
.ChooseBlock
|
||||
{
|
||||
display:flex;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.CName
|
||||
{
|
||||
width:130px;
|
||||
font-weight: 700;
|
||||
height: 100%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#ItemBlockArea
|
||||
{
|
||||
display:flex;
|
||||
overflow-y:scroll;
|
||||
flex-wrap:wrap;
|
||||
flex-direction: row;
|
||||
padding: 0 0 0 8px;
|
||||
}
|
||||
|
||||
.MItem
|
||||
{
|
||||
width:33%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 4px !important;
|
||||
top: -100px; /* ORCA this will be activated when item filtered with position:absolute */
|
||||
}
|
||||
|
||||
.MItem label
|
||||
{
|
||||
margin-right: 0px !important;
|
||||
}
|
||||
|
||||
#NoticeMask
|
||||
{
|
||||
background-color: #000;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.05;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#NoticeBody
|
||||
{
|
||||
display: none;
|
||||
width: 500px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background-color: inherit;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 200px;
|
||||
margin-left: -250px;
|
||||
}
|
||||
|
||||
#NoticeBar
|
||||
{
|
||||
background-color: var(--main-color);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#NoticeContent
|
||||
{
|
||||
padding: 4mm 10mm;
|
||||
}
|
||||
|
||||
|
||||
#NoticeBtns
|
||||
{
|
||||
margin-top: 4mm;
|
||||
display: flex;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
#GotoNetPluginBtn
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* ORCA column browser */
|
||||
|
||||
#Content {
|
||||
padding: 10px 15px 5px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cbr-browser-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: 210px auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cbr-column:last-child {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cbr-column:nth-child(-n+2) {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-column .CValues {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.CValues label {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.cbr-column-title-container {
|
||||
position: sticky;
|
||||
background: var(--bg-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-search-bar,
|
||||
.cbr-filter-bar {
|
||||
font-size: 16px;
|
||||
background: var(--bg-color-secondary);
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 27px 2px 27px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.cbr-search-bar {
|
||||
width: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.cbr-filter-bar {
|
||||
border-color: var(--border-color);
|
||||
width: 160px;
|
||||
height:24px;
|
||||
}
|
||||
|
||||
.cbr-column-title-container .ComboBox > select {
|
||||
margin: 3px 0;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.cbr-column-title-container input:is(:hover,:focus) {
|
||||
border-color: var(--main-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cbr-column-title-container input:is(:focus) {
|
||||
background: var(--focus-bg-box);
|
||||
}
|
||||
|
||||
.cbr-filter-box {
|
||||
position: relative;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.list-item-count {
|
||||
color:var(--fg-color-label);
|
||||
margin-left:10px
|
||||
}
|
||||
|
||||
.cbr-filter-btns {
|
||||
display: flex;
|
||||
margin: 5px 5px 5px auto;
|
||||
}
|
||||
|
||||
.cbr-filter-btns div:first-of-type {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.cbr-filter-mode-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clear-icon,
|
||||
.search-icon,
|
||||
.filter-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
-webkit-mask-image: var(--url);
|
||||
mask-image: var(--url);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--icon-color);
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.filter-icon {--url: var(--icon-filter)}
|
||||
.search-icon {--url: var(--icon-search)}
|
||||
.clear-icon {--url: var(--icon-input-clear)}
|
||||
|
||||
.search-icon,
|
||||
.filter-icon {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
right: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cbr-search-bar:not(:placeholder-shown) ~ .clear-icon,
|
||||
.cbr-filter-bar:not(:placeholder-shown) ~ .clear-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input[onclear="1"]{
|
||||
cursor:default
|
||||
}
|
||||
|
||||
.cbr-search-placeholder,
|
||||
.cbr-filter-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--fg-color-label);
|
||||
pointer-events: none;
|
||||
line-height: 24px;
|
||||
left: 27px;
|
||||
}
|
||||
|
||||
.cbr-search-bar:not(:placeholder-shown) + .cbr-search-placeholder,
|
||||
.cbr-filter-bar:not(:placeholder-shown) + .cbr-filter-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cbr-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cbr-content div {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cbr-content label {
|
||||
margin-right: 0 !important;
|
||||
padding: 1px 0 !important;
|
||||
}
|
||||
|
||||
.cbr-content div.cbr-no-items {
|
||||
#GotoNetPluginBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
|
||||
var m_ProfileItem;
|
||||
|
||||
var FilamentPriority=new Array( "pla","abs","pet","tpu","pc");
|
||||
var VendorPriority=new Array("generic");
|
||||
|
||||
function OnInit()
|
||||
{
|
||||
TranslatePage();
|
||||
|
||||
RequestProfile();
|
||||
|
||||
}
|
||||
|
||||
function RequestProfile()
|
||||
{
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="request_userguide_profile";
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
function HandleStudio(pVal)
|
||||
@@ -30,537 +14,19 @@ function HandleStudio(pVal)
|
||||
{
|
||||
m_ProfileItem=pVal['response'];
|
||||
SortUI();
|
||||
InstallNetworkPlugin();
|
||||
}
|
||||
}
|
||||
|
||||
function GetFilamentShortname( sName )
|
||||
function InstallNetworkPlugin()
|
||||
{
|
||||
let sShort=sName.split('@')[0].trim();
|
||||
|
||||
return sShort;
|
||||
}
|
||||
|
||||
|
||||
function SortUI()
|
||||
{
|
||||
var ModelList=new Array();
|
||||
|
||||
let nMode=m_ProfileItem["model"].length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let OneMode=m_ProfileItem["model"][n];
|
||||
|
||||
if( OneMode["nozzle_selected"]!="" )
|
||||
ModelList.push(OneMode);
|
||||
}
|
||||
|
||||
//model
|
||||
let HtmlMode='';
|
||||
nMode=ModelList.length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let sModel=ModelList[n];
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
HtmlMode+='<label><input type="checkbox" mode="'+sModel['model']+'" nozzle="'+sModel['nozzle_selected']+'" onChange="MachineClick()" /><span>'+sModel['model']+'</span></label>';
|
||||
}
|
||||
|
||||
$('#MachineList .CValues').append(HtmlMode);
|
||||
$('#MachineList .CValues input').prop("checked",true);
|
||||
//if(nMode<=1)
|
||||
//{
|
||||
// $('#MachineList').hide();
|
||||
//}
|
||||
|
||||
//Filament - Create sorted array with generic vendor first
|
||||
let FilamentArray=new Array();
|
||||
let GenericFilamentArray=new Array();
|
||||
for( let key in m_ProfileItem['filament'] )
|
||||
{
|
||||
let OneFila=m_ProfileItem['filament'][key];
|
||||
if(OneFila['vendor'].toLowerCase() === 'generic')
|
||||
GenericFilamentArray.push({key: key, data: OneFila});
|
||||
else
|
||||
FilamentArray.push({key: key, data: OneFila});
|
||||
}
|
||||
// Combine arrays with generic filaments first
|
||||
let SortedFilamentArray = GenericFilamentArray.concat(FilamentArray);
|
||||
|
||||
let HtmlFilament='';
|
||||
let SelectNumber=0;
|
||||
|
||||
var TypeHtmlArray={};
|
||||
var VendorHtmlArray={};
|
||||
for( let n=0; n<SortedFilamentArray.length; n++ )
|
||||
{
|
||||
let filamentItem = SortedFilamentArray[n];
|
||||
let key = filamentItem.key;
|
||||
let OneFila = filamentItem.data;
|
||||
|
||||
//alert(JSON.stringify(OneFila));
|
||||
|
||||
let fWholeName=OneFila['name'].trim();
|
||||
let fShortName=GetFilamentShortname( OneFila['name'] );
|
||||
let fVendor=OneFila['vendor'];
|
||||
let fType=OneFila['type'];
|
||||
let fSelect=OneFila['selected'];
|
||||
let fModel=OneFila['models']
|
||||
|
||||
|
||||
let bFind=false;
|
||||
//let bCheck=$("#MachineList input:first").prop("checked");
|
||||
if( fModel=='')
|
||||
{
|
||||
// Orca: hide
|
||||
bFind=true;
|
||||
}
|
||||
else
|
||||
{
|
||||
//check in modellist
|
||||
let nModelAll=ModelList.length;
|
||||
for(let m=0;m<nModelAll;m++)
|
||||
{
|
||||
let sOne=ModelList[m];
|
||||
|
||||
let OneName=sOne['model'];
|
||||
let NozzleArray=sOne["nozzle_selected"].split(';');
|
||||
|
||||
let nNozzle=NozzleArray.length;
|
||||
|
||||
for( let b=0;b<nNozzle;b++ )
|
||||
{
|
||||
let nowModel= OneName+"++"+NozzleArray[b];
|
||||
if(fModel.indexOf(nowModel)>=0)
|
||||
{
|
||||
bFind=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(bFind)
|
||||
{
|
||||
//Type
|
||||
let LowType=fType.toLowerCase();
|
||||
if(!TypeHtmlArray.hasOwnProperty(LowType))
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlType='<label><input type="checkbox" filatype="'+fType+'" onChange="FilaClick()" /><span>'+fType+'</span></label>';
|
||||
|
||||
TypeHtmlArray[LowType]=HtmlType;
|
||||
}
|
||||
|
||||
//Vendor
|
||||
let lowVendor=fVendor.toLowerCase();
|
||||
if(!VendorHtmlArray.hasOwnProperty(lowVendor))
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlVendor='<label><input type="checkbox" vendor="'+fVendor+'" onChange="VendorClick()" /><span>'+fVendor+'</span></label>';
|
||||
|
||||
VendorHtmlArray[lowVendor]=HtmlVendor;
|
||||
}
|
||||
|
||||
//Filament
|
||||
let pFila=$("#ItemBlockArea input[vendor='"+fVendor+"'][filatype='"+fType+"'][name='"+fShortName+"']");
|
||||
if(pFila.length==0)
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlFila='<label class="MItem"><input type="checkbox" onChange="UpdateStats()" vendor="'+fVendor+'" filatype="'+fType+'" filalist="'+fWholeName+';'+'" model="'+fModel+'" name="'+fShortName+'" /><span>'+fShortName+'</span></label>';
|
||||
|
||||
$("#ItemBlockArea").append(HtmlFila);
|
||||
}
|
||||
else
|
||||
{
|
||||
let strModel=pFila.attr("model");
|
||||
let strFilalist=pFila.attr("filalist");
|
||||
|
||||
if(strModel == '' || fModel == '')
|
||||
pFila.attr("model", '');
|
||||
else
|
||||
pFila.attr("model", strModel+fModel);
|
||||
|
||||
pFila.attr("filalist", strFilalist+fWholeName+';');
|
||||
}
|
||||
|
||||
if(fSelect*1==1)
|
||||
{
|
||||
//alert( fWholeName+' - '+fShortName+' - '+fVendor+' - '+fType+' - '+fSelect+' - '+fModel );
|
||||
|
||||
$("#ItemBlockArea input[vendor='"+fVendor+"'][filatype='"+fType+"'][name='"+fShortName+"']").prop("checked",true);
|
||||
SelectNumber++;
|
||||
}
|
||||
// else
|
||||
// $("#ItemBlockArea input[vendor='"+fVendor+"'][model='"+fModel+"'][filatype='"+fType+"'][name='"+key+"']").prop("checked",false);
|
||||
}
|
||||
}
|
||||
|
||||
//Sort TypeArray
|
||||
let TypeAdvNum=FilamentPriority.length;
|
||||
for( let n=0;n<TypeAdvNum;n++ )
|
||||
{
|
||||
let strType=FilamentPriority[n];
|
||||
|
||||
if( TypeHtmlArray.hasOwnProperty( strType ) )
|
||||
{
|
||||
$("#FilatypeList .CValues").append( TypeHtmlArray[strType] );
|
||||
delete( TypeHtmlArray[strType] );
|
||||
}
|
||||
}
|
||||
for(let key in TypeHtmlArray )
|
||||
{
|
||||
$("#FilatypeList .CValues").append( TypeHtmlArray[key] );
|
||||
}
|
||||
$("#FilatypeList .CValues input").prop("checked",true);
|
||||
|
||||
//Sort VendorArray
|
||||
let VendorAdvNum=VendorPriority.length;
|
||||
for( let n=0;n<VendorAdvNum;n++ )
|
||||
{
|
||||
let strVendor=VendorPriority[n];
|
||||
|
||||
if( VendorHtmlArray.hasOwnProperty( strVendor ) )
|
||||
{
|
||||
$("#VendorList .CValues").append( VendorHtmlArray[strVendor] );
|
||||
delete( VendorHtmlArray[strVendor] );
|
||||
}
|
||||
}
|
||||
for(let key in VendorHtmlArray )
|
||||
{
|
||||
$("#VendorList .CValues").append( VendorHtmlArray[key] );
|
||||
}
|
||||
$("#VendorList .CValues input").prop("checked",true);
|
||||
|
||||
//------
|
||||
if(SelectNumber==0)
|
||||
ChooseDefaultFilament();
|
||||
|
||||
//--If Need Install Network Plugin
|
||||
if(m_ProfileItem["network_plugin_install"]!='1' || (m_ProfileItem["network_plugin_install"]=='1' && m_ProfileItem["network_plugin_compability"]=='0') )
|
||||
{
|
||||
$("#AcceptBtn").hide();
|
||||
$("#GotoNetPluginBtn").show();
|
||||
}
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
|
||||
function ChooseAllMachine()
|
||||
{
|
||||
let bCheck=$("#MachineList input:first").prop("checked");
|
||||
|
||||
$("#MachineList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function MachineClick()
|
||||
{
|
||||
let nChecked=$("#MachineList input:gt(0):checked").length
|
||||
let nAll =$("#MachineList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked)
|
||||
{
|
||||
$("#MachineList input:first").prop("checked",true);
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#MachineList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function ChooseAllFilament()
|
||||
{
|
||||
let bCheck=$("#FilatypeList input:first").prop("checked");
|
||||
$("#FilatypeList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function FilaClick()
|
||||
{
|
||||
let nChecked=$("#FilatypeList input:gt(0):checked").length
|
||||
let nAll =$("#FilatypeList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked)
|
||||
{
|
||||
$("#FilatypeList input:first").prop("checked",true);
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#FilatypeList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function ChooseAllVendor()
|
||||
{
|
||||
let bCheck=$("#VendorList input:first").prop("checked");
|
||||
$("#VendorList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function VendorClick()
|
||||
{
|
||||
let nChecked=$("#VendorList input:gt(0):checked").length
|
||||
let nAll =$("#VendorList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked)
|
||||
{
|
||||
$("#VendorList input:first").prop("checked",true);
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#VendorList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function SortFilament()
|
||||
{
|
||||
let FilaNodes=$("#ItemBlockArea .MItem");
|
||||
let nFilament=FilaNodes.length;
|
||||
//$("#ItemBlockArea .MItem").hide();
|
||||
|
||||
//ModelList
|
||||
let pModel=$("#MachineList input:checked");
|
||||
let nModel=pModel.length;
|
||||
let ModelList=new Array();
|
||||
for(let n=0;n<nModel;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
|
||||
let mName=OneModel.getAttribute("mode");
|
||||
if( mName=='all' )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
let mNozzle=OneModel.getAttribute("nozzle");
|
||||
let NozzleArray=mNozzle.split(';');
|
||||
|
||||
for( let bb=0;bb<NozzleArray.length;bb++ )
|
||||
{
|
||||
let NewModel='['+mName+'++'+NozzleArray[bb]+']';
|
||||
|
||||
ModelList.push( NewModel );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//TypeList
|
||||
let pType=$("#FilatypeList input:gt(0):checked");
|
||||
let nType=pType.length;
|
||||
let TypeList=new Array();
|
||||
for(let n=0;n<nType;n++)
|
||||
{
|
||||
let OneType=pType[n];
|
||||
TypeList.push( OneType.getAttribute("filatype") );
|
||||
}
|
||||
|
||||
//VendorList
|
||||
let pVendor=$("#VendorList input:gt(0):checked");
|
||||
let nVendor=pVendor.length;
|
||||
let VendorList=new Array();
|
||||
for(let n=0;n<nVendor;n++)
|
||||
{
|
||||
let OneVendor=pVendor[n];
|
||||
VendorList.push( OneVendor.getAttribute("vendor") );
|
||||
}
|
||||
|
||||
|
||||
//Update Filament UI
|
||||
for(let m=0;m<nFilament;m++)
|
||||
{
|
||||
let OneNode=FilaNodes[m];
|
||||
let OneFF=OneNode.getElementsByTagName("input")[0];
|
||||
|
||||
let fModel=OneFF.getAttribute("model");
|
||||
let fVendor=OneFF.getAttribute("vendor");
|
||||
let fType=OneFF.getAttribute("filatype");
|
||||
let fName=OneFF.getAttribute("name");
|
||||
|
||||
if(TypeList.in_array(fType) && VendorList.in_array(fVendor))
|
||||
{
|
||||
let HasModel=false;
|
||||
for(let m=0;m<ModelList.length;m++)
|
||||
{
|
||||
let ModelSrc=ModelList[m];
|
||||
|
||||
if( fModel.indexOf(ModelSrc)>=0)
|
||||
{
|
||||
HasModel=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(HasModel || fModel=='')
|
||||
$(OneNode).show();
|
||||
else
|
||||
$(OneNode).hide();
|
||||
}
|
||||
else{
|
||||
$(OneNode).hide();
|
||||
//alert(fName) //debug non common filament type
|
||||
}
|
||||
}
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function UpdateStats()
|
||||
{
|
||||
let $i = $("#ItemBlockArea");
|
||||
let $allItems = $i.find(".MItem");
|
||||
let $visibleItems = $i.find(".MItem:visible");
|
||||
let $filteredItems = $visibleItems.filter(function() { return $(this).css('position') !== 'absolute'});
|
||||
let visibleCount = Math.min($filteredItems.length, $visibleItems.length);
|
||||
|
||||
$(".list-item-count").text(
|
||||
$i.find("input:checked").length + " / " +
|
||||
$allItems.length +
|
||||
($allItems.length > visibleCount ? (" [" + visibleCount + "]") : "") // filtered items
|
||||
);
|
||||
}
|
||||
|
||||
function ChooseDefaultFilament()
|
||||
{
|
||||
//ModelList
|
||||
let pModel=$("#MachineList input:gt(0)");
|
||||
let nModel=pModel.length;
|
||||
let ModelList=new Array();
|
||||
for(let n=0;n<nModel;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
ModelList.push( OneModel.getAttribute("mode") );
|
||||
}
|
||||
|
||||
//DefaultMaterialList
|
||||
let DefaultMaterialString=new Array();
|
||||
let nMode=m_ProfileItem["model"].length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let OneMode=m_ProfileItem["model"][n];
|
||||
let ModeName=OneMode['model'];
|
||||
let DefaultM=OneMode['materials'];
|
||||
|
||||
if( ModelList.indexOf(ModeName)>-1 )
|
||||
{
|
||||
DefaultMaterialString+=OneMode['materials']+';';
|
||||
}
|
||||
}
|
||||
|
||||
let DefaultMaterialArray=DefaultMaterialString.split(';');
|
||||
//alert(DefaultMaterialString);
|
||||
|
||||
//Filament
|
||||
let FilaNodes=$("#ItemBlockArea .MItem");
|
||||
let nFilament=FilaNodes.length;
|
||||
for(let m=0;m<nFilament;m++)
|
||||
{
|
||||
let OneNode=FilaNodes[m];
|
||||
let OneFF=OneNode.getElementsByTagName("input")[0];
|
||||
$(OneFF).prop("checked",false);
|
||||
|
||||
let filamentList=GetFilamentShortname(OneFF.getAttribute("filalist"));
|
||||
//alert(filamentList);
|
||||
let filamentArray=filamentList.split(';')
|
||||
|
||||
let HasModel=false;
|
||||
let NowFilaLength=filamentArray.length;
|
||||
for(let p=0;p<NowFilaLength;p++)
|
||||
{
|
||||
let NowFila=filamentArray[p];
|
||||
|
||||
if( NowFila!='' && DefaultMaterialArray.indexOf(NowFila)>-1)
|
||||
{
|
||||
HasModel=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(HasModel)
|
||||
$(OneFF).prop("checked",true);
|
||||
}
|
||||
|
||||
ShowNotice(0);
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function SelectAllFilament( nShow )
|
||||
{
|
||||
// ORCA add ability to only select / unselect filted items
|
||||
if (document.querySelector('.cbr-filter-bar').value) {
|
||||
$('#ItemBlockArea .MItem:visible input')
|
||||
.filter(function() {return $(this).closest('.MItem').css('position') !== 'absolute'})
|
||||
.prop("checked", nShow != 0);
|
||||
}
|
||||
else {
|
||||
$('#ItemBlockArea .MItem:visible input').prop("checked",nShow!=0);
|
||||
}
|
||||
}
|
||||
|
||||
function ShowNotice( nShow )
|
||||
{
|
||||
if(nShow==0)
|
||||
{
|
||||
$("#NoticeMask").hide();
|
||||
$("#NoticeBody").hide();
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#NoticeMask").show();
|
||||
$("#NoticeBody").show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function ResponseFilamentResult()
|
||||
{
|
||||
let FilaSelectedList= $("#ItemBlockArea input:checked");
|
||||
let nAll=FilaSelectedList.length;
|
||||
|
||||
if( nAll==0 )
|
||||
{
|
||||
ShowNotice(1);
|
||||
return false;
|
||||
}
|
||||
|
||||
let FilaArray=new Array();
|
||||
for(let n=0;n<nAll;n++)
|
||||
{
|
||||
let strFilalist=FilaSelectedList[n].getAttribute("filalist");
|
||||
if(strFilalist) {
|
||||
let filaNames = strFilalist.split(';');
|
||||
for(let i=0; i<filaNames.length; i++) {
|
||||
let fname = filaNames[i].trim();
|
||||
if(fname !== '')
|
||||
FilaArray.push(fname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="save_userguide_filaments";
|
||||
tSend['data']={};
|
||||
tSend['data']['filament']=FilaArray;
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function ReturnPreviewPage()
|
||||
{
|
||||
let nMode=m_ProfileItem["model"].length;
|
||||
@@ -571,7 +37,6 @@ function ReturnPreviewPage()
|
||||
document.location.href="../21/index.html";
|
||||
}
|
||||
|
||||
|
||||
function GotoNetPluginPage()
|
||||
{
|
||||
let bRet=ResponseFilamentResult();
|
||||
@@ -596,8 +61,3 @@ function FinishGuide()
|
||||
}
|
||||
//window.location.href="../6/index.html";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
250
resources/web/guide/22/common.css
Normal file
250
resources/web/guide/22/common.css
Normal file
@@ -0,0 +1,250 @@
|
||||
#Content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ChooseBlock {
|
||||
display:flex;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.CName {
|
||||
width:130px;
|
||||
font-weight: 700;
|
||||
height: 100%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#ItemBlockArea {
|
||||
display:flex;
|
||||
overflow-y:scroll;
|
||||
flex-wrap:wrap;
|
||||
flex-direction: row;
|
||||
padding: 0 0 0 8px;
|
||||
}
|
||||
|
||||
.MItem {
|
||||
width:33%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 4px !important;
|
||||
top: -100px; /* ORCA this will be activated when item filtered with position:absolute */
|
||||
}
|
||||
|
||||
.MItem label {
|
||||
margin-right: 0px !important;
|
||||
}
|
||||
|
||||
/* ORCA COLUMN BROWSER */
|
||||
.cbr-browser-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: 210px auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cbr-column:last-child {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cbr-column:nth-child(-n+2) {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-column .CValues {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.CValues label {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.cbr-column-title-container {
|
||||
position: sticky;
|
||||
background: var(--bg-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-search-bar,
|
||||
.cbr-filter-bar {
|
||||
font-size: 16px;
|
||||
background: var(--bg-color-secondary);
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 27px 2px 27px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.cbr-search-bar {
|
||||
width: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.cbr-filter-bar {
|
||||
border-color: var(--border-color);
|
||||
width: 160px;
|
||||
height:24px;
|
||||
}
|
||||
|
||||
.cbr-column-title-container .ComboBox > select {
|
||||
margin: 3px 0;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.cbr-column-title-container input:is(:hover,:focus) {
|
||||
border-color: var(--main-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cbr-column-title-container input:is(:focus) {
|
||||
background: var(--focus-bg-box);
|
||||
}
|
||||
|
||||
.cbr-filter-box {
|
||||
position: relative;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.list-item-count {
|
||||
color:var(--fg-color-label);
|
||||
margin-left:10px
|
||||
}
|
||||
|
||||
.cbr-filter-btns {
|
||||
display: flex;
|
||||
margin: 5px 5px 5px auto;
|
||||
}
|
||||
|
||||
.cbr-filter-btns div:first-of-type {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.cbr-filter-mode-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clear-icon,
|
||||
.search-icon,
|
||||
.filter-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
-webkit-mask-image: var(--url);
|
||||
mask-image: var(--url);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--icon-color);
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.filter-icon {--url: var(--icon-filter)}
|
||||
.search-icon {--url: var(--icon-search)}
|
||||
.clear-icon {--url: var(--icon-input-clear)}
|
||||
|
||||
.search-icon,
|
||||
.filter-icon {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
right: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cbr-search-bar:not(:placeholder-shown) ~ .clear-icon,
|
||||
.cbr-filter-bar:not(:placeholder-shown) ~ .clear-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input[onclear="1"] {
|
||||
cursor:default
|
||||
}
|
||||
|
||||
.cbr-search-placeholder,
|
||||
.cbr-filter-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--fg-color-label);
|
||||
pointer-events: none;
|
||||
line-height: 24px;
|
||||
left: 27px;
|
||||
}
|
||||
|
||||
.cbr-search-bar:not(:placeholder-shown) + .cbr-search-placeholder,
|
||||
.cbr-filter-bar:not(:placeholder-shown) + .cbr-filter-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cbr-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cbr-content div {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cbr-content label {
|
||||
margin-right: 0 !important;
|
||||
padding: 1px 0 !important;
|
||||
}
|
||||
|
||||
.cbr-content div.cbr-no-items {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* NOTICE POPUP */
|
||||
#NoticeMask {
|
||||
background-color: #000;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.05;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#NoticeBody {
|
||||
display: none;
|
||||
width: 500px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background-color: inherit;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 200px;
|
||||
margin-left: -250px;
|
||||
}
|
||||
|
||||
#NoticeBar {
|
||||
background-color: var(--main-color);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#NoticeContent {
|
||||
padding: 4mm 10mm;
|
||||
}
|
||||
|
||||
#NoticeBtns {
|
||||
margin-top: 4mm;
|
||||
display: flex;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
614
resources/web/guide/22/common.js
Normal file
614
resources/web/guide/22/common.js
Normal file
@@ -0,0 +1,614 @@
|
||||
var m_ProfileItem;
|
||||
|
||||
var FilamentPriority = new Array( "pla","abs","pet","tpu","pc");
|
||||
var VendorPriority = new Array("generic");
|
||||
|
||||
function RequestProfile()
|
||||
{
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="request_userguide_profile";
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
function GetFilamentShortname( sName )
|
||||
{
|
||||
let sShort=sName.split('@')[0].trim();
|
||||
|
||||
return sShort;
|
||||
}
|
||||
|
||||
function SortUI()
|
||||
{
|
||||
var ModelList=new Array();
|
||||
|
||||
let nMode=m_ProfileItem["model"].length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let OneMode=m_ProfileItem["model"][n];
|
||||
|
||||
if( OneMode["nozzle_selected"]!="" )
|
||||
ModelList.push(OneMode);
|
||||
}
|
||||
|
||||
//model
|
||||
let HtmlMode='';
|
||||
nMode=ModelList.length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let sModel=ModelList[n];
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
HtmlMode+='<label><input type="checkbox" mode="'+sModel['model']+'" nozzle="'+sModel['nozzle_selected']+'" onChange="MachineClick()" /><span>'+sModel['model']+'</span></label>';
|
||||
}
|
||||
|
||||
$('#MachineList .CValues').append(HtmlMode);
|
||||
$('#MachineList .CValues input').prop("checked",true);
|
||||
//if(nMode<=1)
|
||||
//{
|
||||
// $('#MachineList').hide();
|
||||
//}
|
||||
|
||||
//Filament - Create sorted array with generic vendor first
|
||||
let FilamentArray=new Array();
|
||||
let GenericFilamentArray=new Array();
|
||||
for( let key in m_ProfileItem['filament'] )
|
||||
{
|
||||
let OneFila=m_ProfileItem['filament'][key];
|
||||
if(OneFila['vendor'].toLowerCase() === 'generic')
|
||||
GenericFilamentArray.push({key: key, data: OneFila});
|
||||
else
|
||||
FilamentArray.push({key: key, data: OneFila});
|
||||
}
|
||||
// Combine arrays with generic filaments first
|
||||
let SortedFilamentArray = GenericFilamentArray.concat(FilamentArray);
|
||||
|
||||
let HtmlFilament='';
|
||||
let SelectNumber=0;
|
||||
|
||||
var TypeHtmlArray={};
|
||||
var VendorHtmlArray={};
|
||||
for( let n=0; n<SortedFilamentArray.length; n++ )
|
||||
{
|
||||
let filamentItem = SortedFilamentArray[n];
|
||||
let key = filamentItem.key;
|
||||
let OneFila = filamentItem.data;
|
||||
|
||||
//alert(JSON.stringify(OneFila));
|
||||
|
||||
let fWholeName=OneFila['name'].trim();
|
||||
let fShortName=GetFilamentShortname( OneFila['name'] );
|
||||
let fVendor=OneFila['vendor'];
|
||||
let fType=OneFila['type'];
|
||||
let fSelect=OneFila['selected'];
|
||||
let fModel=OneFila['models']
|
||||
|
||||
let bFind=false;
|
||||
//let bCheck=$("#MachineList input:first").prop("checked");
|
||||
if( fModel=='')
|
||||
{
|
||||
// Orca: hide
|
||||
bFind=true;
|
||||
}
|
||||
else
|
||||
{
|
||||
//check in modellist
|
||||
let nModelAll=ModelList.length;
|
||||
for(let m=0;m<nModelAll;m++)
|
||||
{
|
||||
let sOne=ModelList[m];
|
||||
|
||||
let OneName=sOne['model'];
|
||||
let NozzleArray=sOne["nozzle_selected"].split(';');
|
||||
|
||||
let nNozzle=NozzleArray.length;
|
||||
|
||||
for( let b=0;b<nNozzle;b++ )
|
||||
{
|
||||
let nowModel= OneName+"++"+NozzleArray[b];
|
||||
if(fModel.indexOf(nowModel)>=0)
|
||||
{
|
||||
bFind=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(bFind)
|
||||
{
|
||||
//Type
|
||||
let LowType=fType.toLowerCase();
|
||||
if(!TypeHtmlArray.hasOwnProperty(LowType))
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlType='<label><input type="checkbox" filatype="'+fType+'" onChange="FilaClick()" /><span>'+fType+'</span></label>';
|
||||
|
||||
TypeHtmlArray[LowType]=HtmlType;
|
||||
}
|
||||
|
||||
//Vendor
|
||||
let lowVendor=fVendor.toLowerCase();
|
||||
if(!VendorHtmlArray.hasOwnProperty(lowVendor))
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlVendor='<label><input type="checkbox" vendor="'+fVendor+'" onChange="VendorClick()" /><span>'+fVendor+'</span></label>';
|
||||
|
||||
VendorHtmlArray[lowVendor]=HtmlVendor;
|
||||
}
|
||||
|
||||
//Filament
|
||||
let pFila=$("#ItemBlockArea input[vendor='"+fVendor+"'][filatype='"+fType+"'][name='"+fShortName+"']");
|
||||
if(pFila.length==0)
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlFila='<label class="MItem"><input type="checkbox" onChange="UpdateStats()" vendor="'+fVendor+'" filatype="'+fType+'" filalist="'+fWholeName+';'+'" model="'+fModel+'" name="'+fShortName+'" /><span>'+fShortName+'</span></label>';
|
||||
|
||||
$("#ItemBlockArea").append(HtmlFila);
|
||||
}
|
||||
else
|
||||
{
|
||||
let strModel=pFila.attr("model");
|
||||
let strFilalist=pFila.attr("filalist");
|
||||
|
||||
if(strModel == '' || fModel == '')
|
||||
pFila.attr("model", '');
|
||||
else
|
||||
pFila.attr("model", strModel+fModel);
|
||||
|
||||
pFila.attr("filalist", strFilalist+fWholeName+';');
|
||||
}
|
||||
|
||||
if(fSelect*1==1)
|
||||
{
|
||||
//alert( fWholeName+' - '+fShortName+' - '+fVendor+' - '+fType+' - '+fSelect+' - '+fModel );
|
||||
|
||||
$("#ItemBlockArea input[vendor='"+fVendor+"'][filatype='"+fType+"'][name='"+fShortName+"']").prop("checked",true);
|
||||
SelectNumber++;
|
||||
}
|
||||
// else
|
||||
// $("#ItemBlockArea input[vendor='"+fVendor+"'][model='"+fModel+"'][filatype='"+fType+"'][name='"+key+"']").prop("checked",false);
|
||||
}
|
||||
}
|
||||
|
||||
//Sort TypeArray
|
||||
let TypeAdvNum=FilamentPriority.length;
|
||||
for( let n=0;n<TypeAdvNum;n++ )
|
||||
{
|
||||
let strType=FilamentPriority[n];
|
||||
|
||||
if( TypeHtmlArray.hasOwnProperty( strType ) )
|
||||
{
|
||||
$("#FilatypeList .CValues").append( TypeHtmlArray[strType] );
|
||||
delete( TypeHtmlArray[strType] );
|
||||
}
|
||||
}
|
||||
for(let key in TypeHtmlArray )
|
||||
{
|
||||
$("#FilatypeList .CValues").append( TypeHtmlArray[key] );
|
||||
}
|
||||
$("#FilatypeList .CValues input").prop("checked",true);
|
||||
|
||||
//Sort VendorArray
|
||||
let VendorAdvNum=VendorPriority.length;
|
||||
for( let n=0;n<VendorAdvNum;n++ )
|
||||
{
|
||||
let strVendor=VendorPriority[n];
|
||||
|
||||
if( VendorHtmlArray.hasOwnProperty( strVendor ) )
|
||||
{
|
||||
$("#VendorList .CValues").append( VendorHtmlArray[strVendor] );
|
||||
delete( VendorHtmlArray[strVendor] );
|
||||
}
|
||||
}
|
||||
for(let key in VendorHtmlArray )
|
||||
{
|
||||
$("#VendorList .CValues").append( VendorHtmlArray[key] );
|
||||
}
|
||||
$("#VendorList .CValues input").prop("checked",true);
|
||||
|
||||
if(SelectNumber==0)
|
||||
ChooseDefaultFilament();
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function ChooseAllMachine()
|
||||
{
|
||||
let bCheck=$("#MachineList input:first").prop("checked");
|
||||
|
||||
$("#MachineList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function MachineClick()
|
||||
{
|
||||
let nChecked=$("#MachineList input:gt(0):checked").length
|
||||
let nAll =$("#MachineList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked) {
|
||||
$("#MachineList input:first").prop("checked",true);
|
||||
}
|
||||
else {
|
||||
$("#MachineList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function ChooseAllFilament()
|
||||
{
|
||||
let bCheck=$("#FilatypeList input:first").prop("checked");
|
||||
$("#FilatypeList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
|
||||
function FilaClick()
|
||||
{
|
||||
let nChecked=$("#FilatypeList input:gt(0):checked").length
|
||||
let nAll =$("#FilatypeList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked) {
|
||||
$("#FilatypeList input:first").prop("checked",true);
|
||||
}
|
||||
else {
|
||||
$("#FilatypeList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function ChooseAllVendor()
|
||||
{
|
||||
let bCheck=$("#VendorList input:first").prop("checked");
|
||||
$("#VendorList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function VendorClick()
|
||||
{
|
||||
let nChecked=$("#VendorList input:gt(0):checked").length
|
||||
let nAll =$("#VendorList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked) {
|
||||
$("#VendorList input:first").prop("checked",true);
|
||||
}
|
||||
else {
|
||||
$("#VendorList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function SortFilament()
|
||||
{
|
||||
let FilaNodes=$("#ItemBlockArea .MItem");
|
||||
let nFilament=FilaNodes.length;
|
||||
//$("#ItemBlockArea .MItem").hide();
|
||||
|
||||
//ModelList
|
||||
let pModel=$("#MachineList input:checked");
|
||||
let nModel=pModel.length;
|
||||
let ModelList=new Array();
|
||||
for(let n=0;n<nModel;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
|
||||
let mName=OneModel.getAttribute("mode");
|
||||
if( mName=='all' )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
let mNozzle=OneModel.getAttribute("nozzle");
|
||||
let NozzleArray=mNozzle.split(';');
|
||||
|
||||
for( let bb=0;bb<NozzleArray.length;bb++ )
|
||||
{
|
||||
let NewModel='['+mName+'++'+NozzleArray[bb]+']';
|
||||
|
||||
ModelList.push( NewModel );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//TypeList
|
||||
let pType=$("#FilatypeList input:gt(0):checked");
|
||||
let nType=pType.length;
|
||||
let TypeList=new Array();
|
||||
for(let n=0;n<nType;n++)
|
||||
{
|
||||
let OneType=pType[n];
|
||||
TypeList.push( OneType.getAttribute("filatype") );
|
||||
}
|
||||
|
||||
//VendorList
|
||||
let pVendor=$("#VendorList input:gt(0):checked");
|
||||
let nVendor=pVendor.length;
|
||||
let VendorList=new Array();
|
||||
for(let n=0;n<nVendor;n++)
|
||||
{
|
||||
let OneVendor=pVendor[n];
|
||||
VendorList.push( OneVendor.getAttribute("vendor") );
|
||||
}
|
||||
|
||||
//Update Filament UI
|
||||
for(let m=0;m<nFilament;m++)
|
||||
{
|
||||
let OneNode=FilaNodes[m];
|
||||
let OneFF=OneNode.getElementsByTagName("input")[0];
|
||||
|
||||
let fModel=OneFF.getAttribute("model");
|
||||
let fVendor=OneFF.getAttribute("vendor");
|
||||
let fType=OneFF.getAttribute("filatype");
|
||||
let fName=OneFF.getAttribute("name");
|
||||
|
||||
if(TypeList.in_array(fType) && VendorList.in_array(fVendor))
|
||||
{
|
||||
let HasModel=false;
|
||||
for(let m=0;m<ModelList.length;m++)
|
||||
{
|
||||
let ModelSrc=ModelList[m];
|
||||
|
||||
if( fModel.indexOf(ModelSrc)>=0)
|
||||
{
|
||||
HasModel=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(HasModel || fModel=='')
|
||||
$(OneNode).show();
|
||||
else
|
||||
$(OneNode).hide();
|
||||
}
|
||||
else{
|
||||
$(OneNode).hide();
|
||||
//alert(fName) //debug non common filament type
|
||||
}
|
||||
}
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function UpdateStats()
|
||||
{
|
||||
let $i = $("#ItemBlockArea");
|
||||
let $allItems = $i.find(".MItem");
|
||||
let $visibleItems = $i.find(".MItem:visible");
|
||||
let $filteredItems = $visibleItems.filter(function() { return $(this).css('position') !== 'absolute'});
|
||||
let visibleCount = Math.min($filteredItems.length, $visibleItems.length);
|
||||
|
||||
$(".list-item-count").text(
|
||||
$i.find("input:checked").length + " / " +
|
||||
$allItems.length +
|
||||
($allItems.length > visibleCount ? (" [" + visibleCount + "]") : "") // filtered items
|
||||
);
|
||||
}
|
||||
|
||||
function SelectAllFilament( nShow )
|
||||
{
|
||||
// ORCA add ability to only select / unselect filted items
|
||||
if (document.querySelector('.cbr-filter-bar').value) {
|
||||
$('#ItemBlockArea .MItem:visible input')
|
||||
.filter(function() {return $(this).closest('.MItem').css('position') !== 'absolute'})
|
||||
.prop("checked", nShow != 0);
|
||||
}
|
||||
else {
|
||||
$('#ItemBlockArea .MItem:visible input').prop("checked",nShow!=0);
|
||||
}
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function ShowNotice( nShow )
|
||||
{
|
||||
if(nShow==0) {
|
||||
$("#NoticeMask").hide();
|
||||
$("#NoticeBody").hide();
|
||||
}
|
||||
else {
|
||||
$("#NoticeMask").show();
|
||||
$("#NoticeBody").show();
|
||||
}
|
||||
}
|
||||
|
||||
function ChooseDefaultFilament()
|
||||
{
|
||||
//ModelList
|
||||
let pModel=$("#MachineList input:gt(0)");
|
||||
let nModel=pModel.length;
|
||||
let ModelList=new Array();
|
||||
for(let n=0;n<nModel;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
ModelList.push( OneModel.getAttribute("mode") );
|
||||
}
|
||||
|
||||
//DefaultMaterialList
|
||||
let DefaultMaterialString = "";
|
||||
let nMode=m_ProfileItem["model"].length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let OneMode=m_ProfileItem["model"][n];
|
||||
let ModeName=OneMode['model'];
|
||||
let DefaultM=OneMode['materials'];
|
||||
|
||||
if( ModelList.indexOf(ModeName)>-1 )
|
||||
{
|
||||
DefaultMaterialString+=OneMode['materials']+';';
|
||||
}
|
||||
}
|
||||
|
||||
let DefaultMaterialArray=DefaultMaterialString.split(';');
|
||||
//alert(DefaultMaterialString);
|
||||
|
||||
//Filament
|
||||
let FilaNodes=$("#ItemBlockArea .MItem");
|
||||
let nFilament=FilaNodes.length;
|
||||
for(let m=0;m<nFilament;m++)
|
||||
{
|
||||
let OneNode=FilaNodes[m];
|
||||
let OneFF=OneNode.getElementsByTagName("input")[0];
|
||||
$(OneFF).prop("checked",false);
|
||||
|
||||
let filamentList=GetFilamentShortname(OneFF.getAttribute("filalist"));
|
||||
//alert(filamentList);
|
||||
let filamentArray=filamentList.split(';')
|
||||
|
||||
let HasModel=false;
|
||||
let NowFilaLength=filamentArray.length;
|
||||
for(let p=0;p<NowFilaLength;p++)
|
||||
{
|
||||
let NowFila=filamentArray[p];
|
||||
|
||||
if( NowFila!='' && DefaultMaterialArray.indexOf(NowFila)>-1)
|
||||
{
|
||||
HasModel=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(HasModel)
|
||||
$(OneFF).prop("checked",true);
|
||||
}
|
||||
|
||||
ShowNotice(0);
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function ResponseFilamentResult()
|
||||
{
|
||||
let FilaSelectedList= $("#ItemBlockArea input:checked");
|
||||
let nAll=FilaSelectedList.length;
|
||||
|
||||
if( nAll==0 )
|
||||
{
|
||||
ShowNotice(1);
|
||||
return false;
|
||||
}
|
||||
|
||||
let FilaArray=new Array();
|
||||
for(let n=0;n<nAll;n++)
|
||||
{
|
||||
let strFilalist=FilaSelectedList[n].getAttribute("filalist");
|
||||
if(strFilalist) {
|
||||
let filaNames = strFilalist.split(';');
|
||||
for(let i=0; i<filaNames.length; i++) {
|
||||
let fname = filaNames[i].trim();
|
||||
if(fname !== '')
|
||||
FilaArray.push(fname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="save_userguide_filaments";
|
||||
tSend['data']={};
|
||||
tSend['data']['filament']=FilaArray;
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addClearBtnEvents(el){
|
||||
el.addEventListener('click', e => {
|
||||
if (el.getAttribute("onclear") == "1") {
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
});
|
||||
el.addEventListener('mousemove', e => {
|
||||
const rc = el.getBoundingClientRect();
|
||||
const onRight = el.value && (e.clientX - rc.left > rc.width - 32);
|
||||
el.setAttribute("onclear", onRight ? "1" : "0");
|
||||
});
|
||||
el.addEventListener('mouseleave', e => {
|
||||
el.setAttribute("onclear", "0");
|
||||
});
|
||||
}
|
||||
|
||||
function initInputEvents(){
|
||||
document.querySelectorAll('.cbr-search-bar').forEach(searchBar => {
|
||||
searchBar.addEventListener('input', function() {
|
||||
const search = this.value.trim().toLowerCase(),
|
||||
list = this.closest('.cbr-column').querySelector('.cbr-content'),
|
||||
items = list.querySelectorAll('label');
|
||||
let hidden = 0;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
if(i == 0){
|
||||
item.style.display ="block";
|
||||
return;
|
||||
};
|
||||
const text = item.querySelector("span").textContent.toLowerCase();
|
||||
const hide = search && !text.includes(search);
|
||||
item.style.display = hide ? "none" : "block";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
|
||||
if(items.length - hidden == 1){
|
||||
items[0].style.display = "none";
|
||||
hidden++;
|
||||
}
|
||||
|
||||
list.querySelector('.cbr-no-items').style.display = (hidden === items.length) ? "block" : "none";
|
||||
});
|
||||
addClearBtnEvents(searchBar);
|
||||
});
|
||||
|
||||
const filterBar = document.querySelector('.cbr-filter-bar');
|
||||
const filterModeFilter = document.querySelector('.cbr-filter-mode-filter' );
|
||||
const filterModeVisible = document.querySelector('.cbr-filter-mode-visible');
|
||||
|
||||
filterBar.addEventListener('input', function() {
|
||||
const search = this.value.trim().toLowerCase();
|
||||
const list = this.closest('.cbr-column').querySelector('.cbr-content');
|
||||
const items = list.querySelectorAll('label');
|
||||
let hidden = 0;
|
||||
|
||||
filterModeFilter.style.display = search ? "block" : "none";
|
||||
filterModeVisible.style.display = search ? "none" : "block";
|
||||
|
||||
const showSel = search == "::checked";
|
||||
const showUnsel = search == "::unchecked";
|
||||
|
||||
if(showSel || showUnsel){
|
||||
items.forEach(item => {
|
||||
const cb = item.querySelector("input");
|
||||
const hide = showSel ? !cb.checked : cb.checked;
|
||||
item.style.position = hide ? "absolute" : "unset";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
}
|
||||
else {
|
||||
items.forEach(item => {
|
||||
const text = item.querySelector("span").textContent.toLowerCase();
|
||||
const hide = search && !text.includes(search);
|
||||
item.style.position = hide ? "absolute" : "unset";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
}
|
||||
|
||||
list.querySelector('.cbr-no-items').style.display = (hidden === items.length) ? "block" : "none";
|
||||
|
||||
UpdateStats();
|
||||
});
|
||||
addClearBtnEvents(filterBar);
|
||||
|
||||
document.querySelector('#filter-tags').addEventListener('change', e => {
|
||||
let v = e.target.value;
|
||||
filterBar.value = v == "1" ? "::checked" : "::unchecked";
|
||||
filterBar.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
filterBar.focus();
|
||||
e.target.value = 0; // reset back to make dropdown items always selectable
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>引导_P21</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" /> <!-- ORCA One for all-->
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../22/common.css" /> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<link rel="stylesheet" type="text/css" href="22.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/dark.css" />
|
||||
<script type="text/javascript" src="test.js"></script>
|
||||
@@ -14,6 +15,7 @@
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.js"></script>
|
||||
<script type="text/javascript" src="../js/common.js"></script>
|
||||
<script type="text/javascript" src="../22/common.js"></script> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<script type="text/javascript" src="./22.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
@@ -129,98 +131,6 @@
|
||||
// e.returnValue = false;
|
||||
//}
|
||||
};
|
||||
|
||||
function addClearBtnEvents(el){
|
||||
el.addEventListener('click', e => {
|
||||
if (el.getAttribute("onclear") == "1") {
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
});
|
||||
el.addEventListener('mousemove', e => {
|
||||
const rc = el.getBoundingClientRect();
|
||||
const onRight = el.value && (e.clientX - rc.left > rc.width - 32);
|
||||
el.setAttribute("onclear", onRight ? "1" : "0");
|
||||
});
|
||||
el.addEventListener('mouseleave', e => {
|
||||
el.setAttribute("onclear", "0");
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.cbr-search-bar').forEach(searchBar => {
|
||||
searchBar.addEventListener('input', function() {
|
||||
const search = this.value.trim().toLowerCase(),
|
||||
list = this.closest('.cbr-column').querySelector('.cbr-content'),
|
||||
items = list.querySelectorAll('label');
|
||||
let hidden = 0;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
if(i == 0){
|
||||
item.style.display ="block";
|
||||
return;
|
||||
};
|
||||
const text = item.querySelector("span").textContent.toLowerCase();
|
||||
const hide = search && !text.includes(search);
|
||||
item.style.display = hide ? "none" : "block";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
|
||||
if(items.length - hidden == 1){
|
||||
items[0].style.display = "none";
|
||||
hidden++;
|
||||
}
|
||||
|
||||
list.querySelector('.cbr-no-items').style.display = (hidden === items.length) ? "block" : "none";
|
||||
});
|
||||
addClearBtnEvents(searchBar);
|
||||
});
|
||||
|
||||
const filterBar = document.querySelector('.cbr-filter-bar');
|
||||
const filterModeFilter = document.querySelector('.cbr-filter-mode-filter' );
|
||||
const filterModeVisible = document.querySelector('.cbr-filter-mode-visible');
|
||||
|
||||
filterBar.addEventListener('input', function() {
|
||||
const search = this.value.trim().toLowerCase();
|
||||
const list = this.closest('.cbr-column').querySelector('.cbr-content');
|
||||
const items = list.querySelectorAll('label');
|
||||
let hidden = 0;
|
||||
|
||||
filterModeFilter.style.display = search ? "block" : "none";
|
||||
filterModeVisible.style.display = search ? "none" : "block";
|
||||
|
||||
const showSel = search == "::checked";
|
||||
const showUnsel = search == "::unchecked";
|
||||
|
||||
if(showSel || showUnsel){
|
||||
items.forEach(item => {
|
||||
const cb = item.querySelector("input");
|
||||
const hide = showSel ? !cb.checked : cb.checked;
|
||||
item.style.position = hide ? "absolute" : "unset";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
}
|
||||
else {
|
||||
items.forEach(item => {
|
||||
const text = item.querySelector("span").textContent.toLowerCase();
|
||||
const hide = search && !text.includes(search);
|
||||
item.style.position = hide ? "absolute" : "unset";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
}
|
||||
|
||||
list.querySelector('.cbr-no-items').style.display = (hidden === items.length) ? "block" : "none";
|
||||
|
||||
UpdateStats();
|
||||
});
|
||||
addClearBtnEvents(filterBar);
|
||||
|
||||
document.querySelector('#filter-tags').addEventListener('change', e => {
|
||||
let v = e.target.value;
|
||||
filterBar.value = v == "1" ? "::checked" : "::unchecked";
|
||||
filterBar.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
filterBar.focus();
|
||||
e.target.value = 0; // reset back to make dropdown items always selectable
|
||||
});
|
||||
|
||||
initInputEvents();
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -1,108 +1,5 @@
|
||||
|
||||
.ChooseBlock
|
||||
{
|
||||
display:flex;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.CName
|
||||
{
|
||||
width:130px;
|
||||
font-weight: 700;
|
||||
height: 100%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#ItemBlockArea
|
||||
{
|
||||
display:flex;
|
||||
overflow-y:scroll;
|
||||
flex-wrap:wrap;
|
||||
flex-direction: row;
|
||||
padding: 0 0 0 8px;
|
||||
}
|
||||
|
||||
.MItem
|
||||
{
|
||||
width:33%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 4px !important;
|
||||
top: -100px; /* ORCA this will be activated when item filtered with position:absolute */
|
||||
}
|
||||
|
||||
.MItem label
|
||||
{
|
||||
margin-right: 0px !important;
|
||||
}
|
||||
|
||||
#NoticeMask
|
||||
{
|
||||
background-color: #000;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.05;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#NoticeBody
|
||||
{
|
||||
display: none;
|
||||
width: 500px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background-color: inherit;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 200px;
|
||||
margin-left: -250px;
|
||||
}
|
||||
|
||||
#NoticeBar
|
||||
{
|
||||
background-color: var(--main-color);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#NoticeContent
|
||||
{
|
||||
padding: 4mm 10mm;
|
||||
}
|
||||
|
||||
|
||||
#NoticeBtns
|
||||
{
|
||||
margin-top: 4mm;
|
||||
display: flex;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
#SystemFilamentsArea
|
||||
{
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#CFilament_Btn_Area
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
#Title
|
||||
{
|
||||
/* TABS SYSTEM / CUSTOM */
|
||||
#Title {
|
||||
margin: 0px 40px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -111,37 +8,44 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#Title div
|
||||
{
|
||||
#Title div {
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#Title div.TitleSelected
|
||||
{
|
||||
#Title div.TitleSelected {
|
||||
height: calc(100% - 6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 6px solid var(--main-color);
|
||||
}
|
||||
|
||||
#Title div.TitleUnselected
|
||||
{
|
||||
#Title div.TitleUnselected {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#CustomFilamentsArea
|
||||
{
|
||||
/* SYSTEM FILAMENTS PAGE */
|
||||
body:has(#SystemFilamentBtn.TitleSelected) #Content { /* :has selector browser support 2023+ */
|
||||
padding: 15px 15px 5px;
|
||||
}
|
||||
|
||||
#SystemFilamentsArea {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* CUSTOM FILAMENTS PAGE */
|
||||
#CustomFilamentsArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#CFilament_List
|
||||
{
|
||||
#CFilament_List {
|
||||
display:flex;
|
||||
overflow-y:auto;
|
||||
flex-wrap:wrap;
|
||||
@@ -152,8 +56,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.CFilament_Item
|
||||
{
|
||||
.CFilament_Item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10%;
|
||||
@@ -164,200 +67,21 @@
|
||||
margin-right: 2%;
|
||||
}
|
||||
|
||||
.CFilament_Name
|
||||
{
|
||||
.CFilament_Name {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap; /* ?????? */
|
||||
text-overflow: ellipsis; /* ????????? */
|
||||
}
|
||||
|
||||
.CFilament_EditBtn
|
||||
{
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.CFilament_EditBtn:hover
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/* ORCA column browser */
|
||||
|
||||
#Content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body:has(#SystemFilamentBtn.TitleSelected) #Content { /* :has selector browser support 2023+ */
|
||||
padding: 15px 15px 5px;
|
||||
}
|
||||
|
||||
.cbr-browser-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: 210px auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cbr-column:last-child {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cbr-column:nth-child(-n+2) {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-column .CValues {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.CValues label {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.cbr-column-title-container {
|
||||
position: sticky;
|
||||
background: var(--bg-color-secondary);
|
||||
#CFilament_Btn_Area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cbr-search-bar,
|
||||
.cbr-filter-bar {
|
||||
font-size: 16px;
|
||||
background: var(--bg-color-secondary);
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 27px 2px 27px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.cbr-search-bar {
|
||||
width: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.cbr-filter-bar {
|
||||
border-color: var(--border-color);
|
||||
width: 160px;
|
||||
height:24px;
|
||||
}
|
||||
|
||||
.cbr-column-title-container .ComboBox > select {
|
||||
margin: 3px 0;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.cbr-column-title-container input:is(:hover,:focus) {
|
||||
border-color: var(--main-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cbr-column-title-container input:is(:focus) {
|
||||
background: var(--focus-bg-box);
|
||||
}
|
||||
|
||||
.cbr-filter-box {
|
||||
position: relative;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.list-item-count {
|
||||
color:var(--fg-color-label);
|
||||
margin-left:10px
|
||||
}
|
||||
|
||||
.cbr-filter-btns {
|
||||
display: flex;
|
||||
margin: 5px 5px 5px auto;
|
||||
}
|
||||
|
||||
.cbr-filter-btns div:first-of-type {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.cbr-filter-mode-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clear-icon,
|
||||
.search-icon,
|
||||
.filter-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
-webkit-mask-image: var(--url);
|
||||
mask-image: var(--url);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--icon-color);
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.filter-icon {--url: var(--icon-filter)}
|
||||
.search-icon {--url: var(--icon-search)}
|
||||
.clear-icon {--url: var(--icon-input-clear)}
|
||||
|
||||
.search-icon,
|
||||
.filter-icon {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
right: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cbr-search-bar:not(:placeholder-shown) ~ .clear-icon,
|
||||
.cbr-filter-bar:not(:placeholder-shown) ~ .clear-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input[onclear="1"]{
|
||||
cursor:default
|
||||
}
|
||||
|
||||
.cbr-search-placeholder,
|
||||
.cbr-filter-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--fg-color-label);
|
||||
pointer-events: none;
|
||||
line-height: 24px;
|
||||
left: 27px;
|
||||
}
|
||||
|
||||
.cbr-search-bar:not(:placeholder-shown) + .cbr-search-placeholder,
|
||||
.cbr-filter-bar:not(:placeholder-shown) + .cbr-filter-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cbr-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cbr-content div {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cbr-content label {
|
||||
margin-right: 0 !important;
|
||||
padding: 1px 0 !important;
|
||||
}
|
||||
|
||||
.cbr-content div.cbr-no-items {
|
||||
display: none;
|
||||
.CFilament_EditBtn {
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
var m_ProfileItem;
|
||||
|
||||
var FilamentPriority=new Array( "pla","abs","pet","tpu","pc");
|
||||
var VendorPriority=new Array("generic");
|
||||
|
||||
function OnInit()
|
||||
{
|
||||
TranslatePage();
|
||||
@@ -15,15 +10,6 @@ function OnInit()
|
||||
//OnSelectMenu(2);
|
||||
}
|
||||
|
||||
function RequestProfile()
|
||||
{
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="request_userguide_profile";
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
function HandleStudio(pVal)
|
||||
{
|
||||
let strCmd=pVal['command'];
|
||||
@@ -40,505 +26,6 @@ function HandleStudio(pVal)
|
||||
}
|
||||
}
|
||||
|
||||
function GetFilamentShortname( sName )
|
||||
{
|
||||
let sShort=sName.split('@')[0].trim();
|
||||
|
||||
return sShort;
|
||||
}
|
||||
|
||||
|
||||
function SortUI()
|
||||
{
|
||||
var ModelList=new Array();
|
||||
|
||||
let nMode=m_ProfileItem["model"].length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let OneMode=m_ProfileItem["model"][n];
|
||||
|
||||
if( OneMode["nozzle_selected"]!="" )
|
||||
ModelList.push(OneMode);
|
||||
}
|
||||
|
||||
|
||||
//model
|
||||
let HtmlMode='';
|
||||
nMode=ModelList.length;
|
||||
for(let n=0;n<nMode;n++)
|
||||
{
|
||||
let sModel=ModelList[n];
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
HtmlMode+='<label><input type="checkbox" mode="'+sModel['model']+'" nozzle="'+sModel['nozzle_selected']+'" onChange="MachineClick()" /><span>'+sModel['model']+'</span></label>';
|
||||
}
|
||||
|
||||
$('#MachineList .CValues').append(HtmlMode);
|
||||
$('#MachineList .CValues input').prop("checked",true);
|
||||
//if(nMode<=1)
|
||||
//{
|
||||
// $('#MachineList').hide();
|
||||
//}
|
||||
|
||||
//Filament - Create sorted array with generic vendor first
|
||||
let FilamentArray=new Array();
|
||||
let GenericFilamentArray=new Array();
|
||||
for( let key in m_ProfileItem['filament'] )
|
||||
{
|
||||
let OneFila=m_ProfileItem['filament'][key];
|
||||
if(OneFila['vendor'].toLowerCase() === 'generic')
|
||||
GenericFilamentArray.push({key: key, data: OneFila});
|
||||
else
|
||||
FilamentArray.push({key: key, data: OneFila});
|
||||
}
|
||||
// Combine arrays with generic filaments first
|
||||
let SortedFilamentArray = GenericFilamentArray.concat(FilamentArray);
|
||||
|
||||
let HtmlFilament='';
|
||||
let SelectNumber=0;
|
||||
|
||||
var TypeHtmlArray={};
|
||||
var VendorHtmlArray={};
|
||||
for( let n=0; n<SortedFilamentArray.length; n++ )
|
||||
{
|
||||
let filamentItem = SortedFilamentArray[n];
|
||||
let key = filamentItem.key;
|
||||
let OneFila = filamentItem.data;
|
||||
|
||||
//alert(JSON.stringify(OneFila));
|
||||
|
||||
let fWholeName=OneFila['name'].trim();
|
||||
let fShortName=GetFilamentShortname( OneFila['name'] );
|
||||
let fVendor=OneFila['vendor'];
|
||||
let fType=OneFila['type'];
|
||||
let fSelect=OneFila['selected'];
|
||||
let fModel=OneFila['models']
|
||||
|
||||
let bFind=false;
|
||||
//let bCheck=$("#MachineList input:first").prop("checked");
|
||||
if( fModel=='')
|
||||
{
|
||||
bFind=true;
|
||||
}
|
||||
else
|
||||
{
|
||||
//check in modellist
|
||||
let nModelAll=ModelList.length;
|
||||
for(let m=0;m<nModelAll;m++)
|
||||
{
|
||||
let sOne=ModelList[m];
|
||||
|
||||
let OneName=sOne['model'];
|
||||
let NozzleArray=sOne["nozzle_selected"].split(';');
|
||||
|
||||
let nNozzle=NozzleArray.length;
|
||||
|
||||
for( let b=0;b<nNozzle;b++ )
|
||||
{
|
||||
let nowModel= OneName+"++"+NozzleArray[b];
|
||||
if(fModel.indexOf(nowModel)>=0)
|
||||
{
|
||||
bFind=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(bFind)
|
||||
{
|
||||
//Type
|
||||
let LowType=fType.toLowerCase();
|
||||
if(!TypeHtmlArray.hasOwnProperty(LowType))
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlType='<label><input type="checkbox" filatype="'+fType+'" onChange="FilaClick()" /><span>'+fType+'</span></label>';
|
||||
|
||||
TypeHtmlArray[LowType]=HtmlType;
|
||||
}
|
||||
|
||||
//Vendor
|
||||
let lowVendor=fVendor.toLowerCase();
|
||||
if(!VendorHtmlArray.hasOwnProperty(lowVendor))
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlVendor='<label><input type="checkbox" vendor="'+fVendor+'" onChange="VendorClick()" /><span>'+fVendor+'</span></label>';
|
||||
|
||||
VendorHtmlArray[lowVendor]=HtmlVendor;
|
||||
}
|
||||
|
||||
//Filament
|
||||
let pFila=$("#ItemBlockArea input[vendor='"+fVendor+"'][filatype='"+fType+"'][name='"+fShortName+"']");
|
||||
if(pFila.length==0)
|
||||
{
|
||||
/* ORCA use label tag to allow checkbox to toggle when user ckicked to text */
|
||||
let HtmlFila='<label class="MItem"><input type="checkbox" onChange="UpdateStats()" vendor="'+fVendor+'" filatype="'+fType+'" filalist="'+fWholeName+';'+'" model="'+fModel+'" name="'+fShortName+'" /><span>'+fShortName+'</span></label>';
|
||||
|
||||
$("#ItemBlockArea").append(HtmlFila);
|
||||
}
|
||||
else
|
||||
{
|
||||
let strModel=pFila.attr("model");
|
||||
let strFilalist=pFila.attr("filalist");
|
||||
|
||||
if(strModel == '' || fModel == '')
|
||||
pFila.attr("model", '');
|
||||
else
|
||||
pFila.attr("model", strModel+fModel);
|
||||
pFila.attr("filalist", strFilalist+fWholeName+';');
|
||||
}
|
||||
|
||||
if(fSelect*1==1)
|
||||
{
|
||||
//alert( fWholeName+' - '+fShortName+' - '+fVendor+' - '+fType+' - '+fSelect+' - '+fModel );
|
||||
|
||||
$("#ItemBlockArea input[vendor='"+fVendor+"'][filatype='"+fType+"'][name='"+fShortName+"']").prop("checked",true);
|
||||
SelectNumber++;
|
||||
}
|
||||
// else
|
||||
// $("#ItemBlockArea input[vendor='"+fVendor+"'][model='"+fModel+"'][filatype='"+fType+"'][name='"+key+"']").prop("checked",false);
|
||||
}
|
||||
}
|
||||
|
||||
//Sort TypeArray
|
||||
let TypeAdvNum=FilamentPriority.length;
|
||||
for( let n=0;n<TypeAdvNum;n++ )
|
||||
{
|
||||
let strType=FilamentPriority[n];
|
||||
|
||||
if( TypeHtmlArray.hasOwnProperty( strType ) )
|
||||
{
|
||||
$("#FilatypeList .CValues").append( TypeHtmlArray[strType] );
|
||||
delete( TypeHtmlArray[strType] );
|
||||
}
|
||||
}
|
||||
for(let key in TypeHtmlArray )
|
||||
{
|
||||
$("#FilatypeList .CValues").append( TypeHtmlArray[key] );
|
||||
}
|
||||
$("#FilatypeList .CValues input").prop("checked",true);
|
||||
|
||||
//Sort VendorArray
|
||||
let VendorAdvNum=VendorPriority.length;
|
||||
for( let n=0;n<VendorAdvNum;n++ )
|
||||
{
|
||||
let strVendor=VendorPriority[n];
|
||||
|
||||
if( VendorHtmlArray.hasOwnProperty( strVendor ) )
|
||||
{
|
||||
$("#VendorList .CValues").append( VendorHtmlArray[strVendor] );
|
||||
delete( VendorHtmlArray[strVendor] );
|
||||
}
|
||||
}
|
||||
for(let key in VendorHtmlArray )
|
||||
{
|
||||
$("#VendorList .CValues").append( VendorHtmlArray[key] );
|
||||
}
|
||||
$("#VendorList .CValues input").prop("checked",true);
|
||||
|
||||
//------
|
||||
if(SelectNumber==0)
|
||||
ChooseDefaultFilament();
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
|
||||
function ChooseAllMachine()
|
||||
{
|
||||
let bCheck=$("#MachineList input:first").prop("checked");
|
||||
|
||||
$("#MachineList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function MachineClick()
|
||||
{
|
||||
let nChecked=$("#MachineList input:gt(0):checked").length
|
||||
let nAll =$("#MachineList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked)
|
||||
{
|
||||
$("#MachineList input:first").prop("checked",true);
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#MachineList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function ChooseAllFilament()
|
||||
{
|
||||
let bCheck=$("#FilatypeList input:first").prop("checked");
|
||||
$("#FilatypeList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function FilaClick()
|
||||
{
|
||||
let nChecked=$("#FilatypeList input:gt(0):checked").length
|
||||
let nAll =$("#FilatypeList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked)
|
||||
{
|
||||
$("#FilatypeList input:first").prop("checked",true);
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#FilatypeList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function ChooseAllVendor()
|
||||
{
|
||||
let bCheck=$("#VendorList input:first").prop("checked");
|
||||
$("#VendorList input").prop("checked",bCheck);
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
function VendorClick()
|
||||
{
|
||||
let nChecked=$("#VendorList input:gt(0):checked").length
|
||||
let nAll =$("#VendorList input:gt(0)").length
|
||||
|
||||
if(nAll==nChecked)
|
||||
{
|
||||
$("#VendorList input:first").prop("checked",true);
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#VendorList input:first").prop("checked",false);
|
||||
}
|
||||
|
||||
SortFilament();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function SortFilament()
|
||||
{
|
||||
let FilaNodes=$("#ItemBlockArea .MItem");
|
||||
let nFilament=FilaNodes.length;
|
||||
//$("#ItemBlockArea .MItem").hide();
|
||||
|
||||
//ModelList
|
||||
let pModel=$("#MachineList input:checked");
|
||||
let nModel=pModel.length;
|
||||
let ModelList=new Array();
|
||||
for(let n=0;n<nModel;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
|
||||
let mName=OneModel.getAttribute("mode");
|
||||
if( mName=='all' )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
let mNozzle=OneModel.getAttribute("nozzle");
|
||||
let NozzleArray=mNozzle.split(';');
|
||||
|
||||
for( let bb=0;bb<NozzleArray.length;bb++ )
|
||||
{
|
||||
let NewModel='['+mName+'++'+NozzleArray[bb]+']';
|
||||
|
||||
ModelList.push( NewModel );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//TypeList
|
||||
let pType=$("#FilatypeList input:gt(0):checked");
|
||||
let nType=pType.length;
|
||||
let TypeList=new Array();
|
||||
for(let n=0;n<nType;n++)
|
||||
{
|
||||
let OneType=pType[n];
|
||||
TypeList.push( OneType.getAttribute("filatype") );
|
||||
}
|
||||
|
||||
//VendorList
|
||||
let pVendor=$("#VendorList input:gt(0):checked");
|
||||
let nVendor=pVendor.length;
|
||||
let VendorList=new Array();
|
||||
for(let n=0;n<nVendor;n++)
|
||||
{
|
||||
let OneVendor=pVendor[n];
|
||||
VendorList.push( OneVendor.getAttribute("vendor") );
|
||||
}
|
||||
|
||||
|
||||
//Update Filament UI
|
||||
for(let m=0;m<nFilament;m++)
|
||||
{
|
||||
let OneNode=FilaNodes[m];
|
||||
let OneFF=OneNode.getElementsByTagName("input")[0];
|
||||
|
||||
let fModel=OneFF.getAttribute("model");
|
||||
let fVendor=OneFF.getAttribute("vendor");
|
||||
let fType=OneFF.getAttribute("filatype");
|
||||
let fName=OneFF.getAttribute("name");
|
||||
|
||||
if(TypeList.in_array(fType) && VendorList.in_array(fVendor))
|
||||
{
|
||||
let HasModel=false;
|
||||
for(let m=0;m<ModelList.length;m++)
|
||||
{
|
||||
let ModelSrc=ModelList[m];
|
||||
|
||||
if( fModel.indexOf(ModelSrc)>=0)
|
||||
{
|
||||
HasModel=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(HasModel || fModel=='')
|
||||
$(OneNode).show();
|
||||
else
|
||||
$(OneNode).hide();
|
||||
}
|
||||
else{
|
||||
$(OneNode).hide();
|
||||
//alert(fName) //debug non common filament type
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function UpdateStats()
|
||||
{
|
||||
let $i = $("#ItemBlockArea");
|
||||
let $allItems = $i.find(".MItem");
|
||||
let $visibleItems = $i.find(".MItem:visible");
|
||||
let $filteredItems = $visibleItems.filter(function() { return $(this).css('position') !== 'absolute'});
|
||||
let visibleCount = Math.min($filteredItems.length, $visibleItems.length);
|
||||
|
||||
$(".list-item-count").text(
|
||||
$i.find("input:checked").length + " / " +
|
||||
$allItems.length +
|
||||
($allItems.length > visibleCount ? (" [" + visibleCount + "]") : "") // filtered items
|
||||
);
|
||||
}
|
||||
|
||||
function ChooseDefaultFilament()
|
||||
{
|
||||
//ModelList
|
||||
let pModel=$("#MachineList input:gt(0):checked");
|
||||
let nModel=pModel.length;
|
||||
let ModelList=new Array();
|
||||
for(let n=0;n<nModel;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
ModelList.push( OneModel.getAttribute("mode") );
|
||||
}
|
||||
|
||||
//Filament
|
||||
let FilaNodes=$("#ItemBlockArea .MItem");
|
||||
let nFilament=FilaNodes.length;
|
||||
for(let m=0;m<nFilament;m++)
|
||||
{
|
||||
let OneNode=FilaNodes[m];
|
||||
let OneFF=OneNode.getElementsByTagName("input")[0];
|
||||
$(OneFF).prop("checked",false);
|
||||
|
||||
let fModel=OneFF.getAttribute("model");
|
||||
|
||||
let HasModel=false;
|
||||
for(let m=0;m<nModel;m++)
|
||||
{
|
||||
let ModelSrc=ModelList[m];
|
||||
|
||||
if( fModel.indexOf(ModelSrc)>=0)
|
||||
{
|
||||
HasModel=true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(HasModel)
|
||||
$(OneFF).prop("checked",true);
|
||||
}
|
||||
|
||||
ShowNotice(0);
|
||||
}
|
||||
|
||||
function SelectAllFilament( nShow )
|
||||
{
|
||||
// ORCA add ability to only select / unselect filted items
|
||||
if (document.querySelector('.cbr-filter-bar').value) {
|
||||
$('#ItemBlockArea .MItem:visible input')
|
||||
.filter(function() {return $(this).closest('.MItem').css('position') !== 'absolute'})
|
||||
.prop("checked", nShow != 0);
|
||||
}
|
||||
else {
|
||||
$('#ItemBlockArea .MItem:visible input').prop("checked",nShow!=0);
|
||||
}
|
||||
|
||||
UpdateStats();
|
||||
}
|
||||
|
||||
function ShowNotice( nShow )
|
||||
{
|
||||
if(nShow==0)
|
||||
{
|
||||
$("#NoticeMask").hide();
|
||||
$("#NoticeBody").hide();
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#NoticeMask").show();
|
||||
$("#NoticeBody").show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function ResponseFilamentResult()
|
||||
{
|
||||
let FilaSelectedList= $("#ItemBlockArea input:checked");
|
||||
let nAll=FilaSelectedList.length;
|
||||
|
||||
if( nAll==0 )
|
||||
{
|
||||
ShowNotice(1);
|
||||
return false;
|
||||
}
|
||||
|
||||
let FilaArray=new Array();
|
||||
for(let n=0;n<nAll;n++)
|
||||
{
|
||||
let strFilalist=FilaSelectedList[n].getAttribute("filalist");
|
||||
if(strFilalist) {
|
||||
let filaNames = strFilalist.split(';');
|
||||
for(let i=0; i<filaNames.length; i++) {
|
||||
let fname = filaNames[i].trim();
|
||||
if(fname !== '')
|
||||
FilaArray.push(fname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="save_userguide_filaments";
|
||||
tSend['data']={};
|
||||
tSend['data']['filament']=FilaArray;
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function CancelSelect()
|
||||
{
|
||||
var tSend={};
|
||||
@@ -549,7 +36,6 @@ function CancelSelect()
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
|
||||
function ConfirmSelect()
|
||||
{
|
||||
let bRet=ResponseFilamentResult();
|
||||
@@ -566,7 +52,6 @@ function ConfirmSelect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function OnSelectMenu( nIndex )
|
||||
{
|
||||
switch(nIndex)
|
||||
@@ -633,7 +118,6 @@ function UpdateCustomFilaments( CFList )
|
||||
$('#CFilament_List').html(strHtml);
|
||||
}
|
||||
|
||||
|
||||
function OnClickCustomFilamentAdd()
|
||||
{
|
||||
//alert('Create New Custom Filament');
|
||||
@@ -657,5 +141,3 @@ function CFEdit( fid )
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>引导_P21</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" /> <!-- ORCA One for all-->
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../22/common.css" /> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<link rel="stylesheet" type="text/css" href="23.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/dark.css" />
|
||||
<script type="text/javascript" src="../js/jquery-3.6.0.min.js"></script>
|
||||
@@ -13,6 +14,7 @@
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.js"></script>
|
||||
<script type="text/javascript" src="../js/common.js"></script>
|
||||
<script type="text/javascript" src="../22/common.js"></script> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<script type="text/javascript" src="./23.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
@@ -150,97 +152,6 @@
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
function addClearBtnEvents(el){
|
||||
el.addEventListener('click', e => {
|
||||
if (el.getAttribute("onclear") == "1") {
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
});
|
||||
el.addEventListener('mousemove', e => {
|
||||
const rc = el.getBoundingClientRect();
|
||||
const onRight = el.value && (e.clientX - rc.left > rc.width - 32);
|
||||
el.setAttribute("onclear", onRight ? "1" : "0");
|
||||
});
|
||||
el.addEventListener('mouseleave', e => {
|
||||
el.setAttribute("onclear", "0");
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.cbr-search-bar').forEach(searchBar => {
|
||||
searchBar.addEventListener('input', function() {
|
||||
const search = this.value.trim().toLowerCase(),
|
||||
list = this.closest('.cbr-column').querySelector('.cbr-content'),
|
||||
items = list.querySelectorAll('label');
|
||||
let hidden = 0;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
if(i == 0){
|
||||
item.style.display ="block";
|
||||
return;
|
||||
};
|
||||
const text = item.querySelector("span").textContent.toLowerCase();
|
||||
const hide = search && !text.includes(search);
|
||||
item.style.display = hide ? "none" : "block";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
|
||||
if(items.length - hidden == 1){
|
||||
items[0].style.display = "none";
|
||||
hidden++;
|
||||
}
|
||||
|
||||
list.querySelector('.cbr-no-items').style.display = (hidden === items.length) ? "block" : "none";
|
||||
});
|
||||
addClearBtnEvents(searchBar);
|
||||
});
|
||||
|
||||
const filterBar = document.querySelector('.cbr-filter-bar');
|
||||
const filterModeFilter = document.querySelector('.cbr-filter-mode-filter' );
|
||||
const filterModeVisible = document.querySelector('.cbr-filter-mode-visible');
|
||||
|
||||
filterBar.addEventListener('input', function() {
|
||||
const search = this.value.trim().toLowerCase();
|
||||
const list = this.closest('.cbr-column').querySelector('.cbr-content');
|
||||
const items = list.querySelectorAll('label');
|
||||
let hidden = 0;
|
||||
|
||||
filterModeFilter.style.display = search ? "block" : "none";
|
||||
filterModeVisible.style.display = search ? "none" : "block";
|
||||
|
||||
const showSel = search == "::checked";
|
||||
const showUnsel = search == "::unchecked";
|
||||
|
||||
if(showSel || showUnsel){
|
||||
items.forEach(item => {
|
||||
const cb = item.querySelector("input");
|
||||
const hide = showSel ? !cb.checked : cb.checked;
|
||||
item.style.position = hide ? "absolute" : "unset";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
}
|
||||
else {
|
||||
items.forEach(item => {
|
||||
const text = item.querySelector("span").textContent.toLowerCase();
|
||||
const hide = search && !text.includes(search);
|
||||
item.style.position = hide ? "absolute" : "unset";
|
||||
if(hide) hidden++;
|
||||
});
|
||||
}
|
||||
|
||||
list.querySelector('.cbr-no-items').style.display = (hidden === items.length) ? "block" : "none";
|
||||
|
||||
UpdateStats();
|
||||
});
|
||||
addClearBtnEvents(filterBar);
|
||||
|
||||
document.querySelector('#filter-tags').addEventListener('change', e => {
|
||||
let v = e.target.value;
|
||||
filterBar.value = v == "1" ? "::checked" : "::unchecked";
|
||||
filterBar.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
filterBar.focus();
|
||||
e.target.value = 0; // reset back to make dropdown items always selectable
|
||||
});
|
||||
|
||||
initInputEvents();
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -1,473 +1,5 @@
|
||||
#Content
|
||||
{
|
||||
overflow-y:auto;
|
||||
padding: 0 10px 0 20px; /* ORCA Specify & Reduce horizontal paddings to fit 4 items per row */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.OneVendorBlock {
|
||||
position: relative;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.OneVendorBlock:last-of-type {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.BlockBanner
|
||||
{
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0px;
|
||||
border-bottom: 2px solid var(--main-color);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
background-color: var(--bg-color-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.BannerBtns
|
||||
{
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-right: 5px; /* ORCA align buttons with end of horizontal separator/line */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.BlockBanner a
|
||||
{
|
||||
line-height: 30px;
|
||||
height: 30px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
padding: 0px 10px;
|
||||
color: var(--fg-color-text);
|
||||
}
|
||||
|
||||
.BlockBanner .modelCount {
|
||||
margin: 0 15px 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
height: 15px;
|
||||
color: var(--fg-color-label);
|
||||
}
|
||||
|
||||
.VendorCheckbox {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.PrinterArea
|
||||
{
|
||||
padding: 7px 0px; /* ORCA Reduce horizontal paddings to fit 4 items per row */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.PrinterBlock
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap:10px;
|
||||
padding: 15px 10px 10px 10px;
|
||||
background-color: var(--bg-color-secondary);
|
||||
position: relative;
|
||||
border: 1px solid transparent
|
||||
}
|
||||
|
||||
.PrinterBlock:hover {
|
||||
background-color: var(--focus-bg-item);
|
||||
border-color:var(--main-color);
|
||||
}
|
||||
|
||||
.PImg {
|
||||
width:120px; /* ORCA use covers as 120x120px but use source file as 240x240 for better quality on hidpi */
|
||||
height:120px; /* ORCA fit image to fill frame */
|
||||
}
|
||||
|
||||
.PrinterInfo,
|
||||
.PrinterInfoMark {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
opacity: 0;
|
||||
border-radius: 11px;
|
||||
line-height: 19px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.PrinterInfo {
|
||||
--card-animation-delay: .8s; /* open info with delay on list / compact view to prevent them appear while mouse movements */
|
||||
--card-info-height: fit-content;
|
||||
left: 4px;
|
||||
width: auto;
|
||||
z-index: 9998;
|
||||
height: var(--card-info-height);
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-color);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
color: var(--fg-color-text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#Content[layout="2"] .PrinterInfo {
|
||||
--card-animation-delay: .3s;
|
||||
--card-info-height: 116px;
|
||||
}
|
||||
|
||||
.PrinterInfo .title {font-weight: 700}
|
||||
.PrinterInfo .value {font-weight: 400}
|
||||
|
||||
.PrinterInfoMark:hover + .PrinterInfo {
|
||||
animation: infoCard 0s forwards var(--card-animation-delay);
|
||||
}
|
||||
|
||||
@keyframes infoCard {100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,.2);
|
||||
}}
|
||||
|
||||
.PrinterInfoMark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--main-color);
|
||||
border: 1px solid var(--main-color);
|
||||
z-index: 9999;
|
||||
color: #FFF;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.PrinterBlock:hover .PrinterInfoMark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.PrinterBlock:hover .PrinterInfoMark:hover {
|
||||
background: var(--main-color-hover);
|
||||
}
|
||||
|
||||
.ModelCheckBox
|
||||
{
|
||||
position: absolute;
|
||||
height: 6px;
|
||||
bottom: 0;
|
||||
left: 10%;
|
||||
width: 80%;
|
||||
background: var(--button-bg-hover)
|
||||
}
|
||||
|
||||
.ModelCheckBox.ModelCheckBoxSelected
|
||||
{
|
||||
background: var(--main-color-fixed)
|
||||
}
|
||||
|
||||
img.ModelThumbnail
|
||||
{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.PName
|
||||
{
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* ORCA */
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
color: var(--fg-color-text);
|
||||
}
|
||||
|
||||
.pNozzel
|
||||
{
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content:flex-start;
|
||||
color: #5A5A5A;
|
||||
padding-left: 0px; /* ORCA Align checkboxes with with model text */
|
||||
}
|
||||
|
||||
.pNozzel input
|
||||
{
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.LayoutSelector {
|
||||
position: absolute;
|
||||
right:21px;
|
||||
top:14px;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabGroup {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-color-alt);
|
||||
}
|
||||
|
||||
.LayoutSelector .icon16 {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabButton {
|
||||
padding: 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.LayoutSelector .TabButton.selected {background: var(--main-color)}
|
||||
.LayoutSelector .TabButton.selected:hover {background: var(--main-color-hover)}
|
||||
.LayoutSelector .TabButton.selected .icon16 {background: #FFF}
|
||||
|
||||
.LayoutSelector .TabButton:nth-of-type(1) .icon16 {--icon-url: var(--icon-layout-list)}
|
||||
.LayoutSelector .TabButton:nth-of-type(2) .icon16 {--icon-url: var(--icon-layout-compact)}
|
||||
.LayoutSelector .TabButton:nth-of-type(3) .icon16 {--icon-url: var(--icon-layout-cover)}
|
||||
|
||||
/* UNIQUE STYLES */
|
||||
#CreateBtn {
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
|
||||
/* LAYOUT */
|
||||
#Content[layout="compact-list"] .PrinterArea {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
#Content[layout="compact-list"] .PImg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#Content[layout="compact-list"] .OneVendorBlock {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#Content[layout="compact-cover"] .PrinterArea {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
#Content[layout="compact-cover"] .PImg {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .PName {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .PrinterBlock {
|
||||
flex-direction: row;
|
||||
padding: 5px 5px 5px 18px;
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .ModelCheckBox {
|
||||
width: 6px;
|
||||
height: 80%;
|
||||
left:0;
|
||||
top:10%
|
||||
}
|
||||
|
||||
#Content[layout|="compact"] .OneVendorBlock:last-of-type {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
/*-----Notice-----*/
|
||||
#NoticeMask
|
||||
{
|
||||
background-color: #000;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
opacity: 0.05;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#NoticeBody
|
||||
{
|
||||
display: none;
|
||||
width: 400px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background-color: inherit;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 200px;
|
||||
margin-left: -200px;
|
||||
}
|
||||
|
||||
#NoticeBar
|
||||
{
|
||||
background-color:#00f0d8;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#NoticeContent
|
||||
{
|
||||
padding: 4mm 10mm;
|
||||
}
|
||||
|
||||
|
||||
#NoticeBtns
|
||||
{
|
||||
margin-top: 4mm;
|
||||
display: flex;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: absolute;
|
||||
left:66px;
|
||||
top: 14px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
z-index: 99;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search:focus-within,
|
||||
.search[hasvalue="1"] {
|
||||
width: calc(100% - 194px);
|
||||
}
|
||||
|
||||
.searchTerm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px 5px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--button-bg-normal);
|
||||
border: 1px solid var(--button-bg-normal);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.searchTerm {
|
||||
transition: background-color .2s
|
||||
}
|
||||
}
|
||||
|
||||
.searchTerm,
|
||||
.search-placeholder {
|
||||
line-height: 24px; /* ORCA center text vertically */
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search:focus-within .searchTerm,
|
||||
.search[hasvalue="1"] .searchTerm {
|
||||
padding-left:33px;
|
||||
background: var(--bg-color);
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.search[hasvalue="1"]:not(:focus-within, :hover) .searchTerm {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.search:not(:focus-within, [hasvalue="1"]) .searchTerm {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search:not(:focus-within, [hasvalue="1"]) .searchTerm:hover {
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: var(--fg-color-disabled);
|
||||
left: 33px;
|
||||
}
|
||||
|
||||
.searchTerm:not(:placeholder-shown) + .search-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.search-icon,
|
||||
.search-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
left: 9px;
|
||||
--icon-url: var(--icon-search);
|
||||
}
|
||||
|
||||
.SidebarBtn {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 14px;
|
||||
padding: 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.SidebarBtn .icon16 {
|
||||
--icon-url: var(--icon-sidebar);
|
||||
}
|
||||
|
||||
#SidebarContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -240px;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#SidebarContainer {
|
||||
transition: background-color .2s, left .2s
|
||||
}
|
||||
}
|
||||
|
||||
#SidebarContainer[open="1"] {
|
||||
left: 0px;
|
||||
pointer-events: all;
|
||||
background: rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
#Sidebar {
|
||||
flex: 0 0 220px;
|
||||
background: var(--bg-color);
|
||||
box-shadow: 5px 0 20px rgba(0,0,0,.2);
|
||||
padding: 15px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#Sidebar .title {
|
||||
font-size: 17px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
padding: 0 0 5px 20px;
|
||||
}
|
||||
|
||||
#Sidebar .SidebarItem {
|
||||
width: 100%;
|
||||
padding: 2px 10px 2px 20px;
|
||||
color:var(--fg-color-text);
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#Sidebar .SidebarItem:hover {
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
#SidebarContainer .back {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,485 +1,15 @@
|
||||
// UNIQUE FUNCTIONS
|
||||
|
||||
// Keep in here for future additions
|
||||
function OnInit()
|
||||
{
|
||||
//let strInput=JSON.stringify(cData);
|
||||
//HandleModelList(cData);
|
||||
|
||||
TranslatePage();
|
||||
|
||||
RequestProfile();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function RequestProfile()
|
||||
{
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="request_userguide_profile";
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
function HandleStudio( pVal )
|
||||
{
|
||||
// alert(strInput);
|
||||
// alert(JSON.stringify(strInput));
|
||||
//
|
||||
// let pVal=IsJson(strInput);
|
||||
// if(pVal==null)
|
||||
// {
|
||||
// alert("Msg Format Error is not Json");
|
||||
// return;
|
||||
// }
|
||||
|
||||
let strCmd=pVal['command'];
|
||||
//alert(strCmd);
|
||||
|
||||
if(strCmd=='response_userguide_profile')
|
||||
{
|
||||
HandleModelList(pVal['response']);
|
||||
}
|
||||
}
|
||||
|
||||
function ShowPrinterThumb(pItem, strImg)
|
||||
{
|
||||
$(pItem).attr('src',strImg);
|
||||
$(pItem).attr('onerror',null);
|
||||
}
|
||||
|
||||
function ChooseModel( vendor, ModelName )
|
||||
{
|
||||
let ChooseItem=$(".ModelCheckBox[vendor='"+vendor+"'][model='"+ModelName+"']");
|
||||
|
||||
if(ChooseItem!=null)
|
||||
{
|
||||
if( $(ChooseItem).hasClass('ModelCheckBoxSelected') )
|
||||
$(ChooseItem).removeClass('ModelCheckBoxSelected');
|
||||
else
|
||||
$(ChooseItem).addClass('ModelCheckBoxSelected');
|
||||
|
||||
SetModelSelect(vendor, ModelName, $(ChooseItem).hasClass('ModelCheckBoxSelected'));
|
||||
}
|
||||
}
|
||||
|
||||
function HandleModelList( pVal )
|
||||
{
|
||||
if( !pVal.hasOwnProperty("model") )
|
||||
return;
|
||||
|
||||
pModel=pVal['model'];
|
||||
|
||||
// ORCA ensure list correctly ordered
|
||||
pModel = pModel.sort((a, b)=>(a["vendor"].localeCompare(b["vendor"])))
|
||||
pModel = [ // move custom printers to top
|
||||
...pModel.filter(i=>i.vendor === "Custom"),
|
||||
...pModel.filter(i=>i.vendor !== "Custom")
|
||||
];
|
||||
|
||||
let nTotal=pModel.length;
|
||||
let ModelHtml={};
|
||||
for(let n=0;n<nTotal;n++)
|
||||
{
|
||||
let OneModel=pModel[n];
|
||||
|
||||
let strVendor=OneModel['vendor'];
|
||||
|
||||
//Add Vendor Html Node
|
||||
if($(".OneVendorBlock[vendor='"+strVendor+"']").length==0)
|
||||
{
|
||||
let sVV=strVendor;
|
||||
if( sVV=="BBL" )
|
||||
sVV="Bambu Lab";
|
||||
if( sVV=="Custom")
|
||||
sVV="Custom Printer";
|
||||
if( sVV=="Other")
|
||||
sVV="Orca colosseum";
|
||||
|
||||
let HtmlNewVendor='<div class="OneVendorBlock" Vendor="'+strVendor+'">'+
|
||||
'<div class="BlockBanner">'+
|
||||
' <a>'+sVV+'</a>'+
|
||||
' <div class="BannerBtns" onClick="ChooseVendor('+"\'"+strVendor+"\'"+')">'+
|
||||
' <div class="modelCount"></div>' +
|
||||
' <input type="checkbox" class="VendorCheckbox"/>'+
|
||||
' </div>'+
|
||||
//' <div class="BannerBtns">'+
|
||||
//' <div class="ButtonStyleConfirm ButtonTypeWindow trans" tid="t11" onClick="SelectPrinterAll('+"\'"+strVendor+"\'"+')">all</div>'+
|
||||
//' <div class="ButtonStyleRegular ButtonTypeWindow trans" tid="t12" onClick="SelectPrinterNone('+"\'"+strVendor+"\'"+')">none</div>'+
|
||||
//' </div>'+
|
||||
'</div>'+
|
||||
'<div class="PrinterArea"> '+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
|
||||
$('#Content').append(HtmlNewVendor);
|
||||
}
|
||||
|
||||
let ModelName=OneModel['model'];
|
||||
|
||||
//Collect Html Node Nozzel Html
|
||||
if( !ModelHtml.hasOwnProperty(strVendor))
|
||||
ModelHtml[strVendor]='';
|
||||
|
||||
ModelHtml[strVendor]+=CreatePrinterBlock(OneModel); // ORCA
|
||||
}
|
||||
|
||||
//Update Nozzel Html Append
|
||||
for( let key in ModelHtml )
|
||||
{
|
||||
$(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").append( ModelHtml[key] );
|
||||
}
|
||||
|
||||
|
||||
//Update Checkbox
|
||||
for(let m=0;m<nTotal;m++)
|
||||
{
|
||||
let OneModel=pModel[m];
|
||||
|
||||
let SelectList=OneModel['nozzle_selected'];
|
||||
if(SelectList!='')
|
||||
{
|
||||
ChooseModel(OneModel['vendor'], OneModel['model']);
|
||||
}
|
||||
}
|
||||
|
||||
const $SidebarVendors = $('#SidebarVendors');
|
||||
let SidebarHTML = "";
|
||||
$(`.OneVendorBlock`).each((i, el)=>{
|
||||
UpdateVendorCheckbox(el.getAttribute("vendor"));
|
||||
SidebarHTML +=`<div class="SidebarItem" onclick="scrollToVendor(this.textContent)">${el.getAttribute('vendor')}</div>`;
|
||||
});
|
||||
$SidebarVendors.html(SidebarHTML)
|
||||
|
||||
// let AlreadySelect=$(".ModelCheckBoxSelected");
|
||||
// let nSelect=AlreadySelect.length;
|
||||
// if(nSelect==0)
|
||||
// {
|
||||
// $("div.OneVendorBlock[vendor='"+BBL+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
// }
|
||||
|
||||
TranslatePage();
|
||||
}
|
||||
|
||||
function scrollToVendor(vendor) {
|
||||
const el = $(".OneVendorBlock[vendor='"+vendor+"']")[0];
|
||||
if (el){
|
||||
document.getElementById('SidebarContainer').setAttribute('open', '0');
|
||||
document.getElementById('Content').scrollTo({top: el.offsetTop, behavior: "smooth"});
|
||||
}
|
||||
}
|
||||
|
||||
function SetModelSelect(vendor, model, checked) {
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor) && !checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor) && checked) {
|
||||
ModelNozzleSelected[vendor] = {};
|
||||
}
|
||||
|
||||
let oVendor = ModelNozzleSelected[vendor];
|
||||
if (oVendor.hasOwnProperty(model) || checked) {
|
||||
oVendor[model] = checked;
|
||||
}
|
||||
|
||||
UpdateVendorCheckbox(vendor)
|
||||
}
|
||||
|
||||
function GetModelSelect(vendor, model) {
|
||||
if (!ModelNozzleSelected.hasOwnProperty(vendor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let oVendor = ModelNozzleSelected[vendor];
|
||||
if (!oVendor.hasOwnProperty(model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return oVendor[model];
|
||||
}
|
||||
|
||||
function FilterModelList(keyword) {
|
||||
|
||||
//Save checkbox state
|
||||
let ModelSelect = $('.ModelCheckBox');
|
||||
for (let n = 0; n < ModelSelect.length; n++) {
|
||||
let OneItem = ModelSelect[n];
|
||||
|
||||
let strModel = OneItem.getAttribute("model");
|
||||
|
||||
let strVendor = OneItem.getAttribute("vendor");
|
||||
|
||||
SetModelSelect(strVendor, strModel, $(OneItem).hasClass('ModelCheckBoxSelected'));
|
||||
}
|
||||
|
||||
$('.search')[0].setAttribute("hasvalue", keyword ? "1" : "0")
|
||||
|
||||
let nTotal = pModel.length;
|
||||
let ModelHtml = {};
|
||||
let kwSplit = keyword.toLowerCase().match(/\S+/g) || [];
|
||||
|
||||
$('#Content').empty();
|
||||
for (let n = 0; n < nTotal; n++) {
|
||||
let OneModel = pModel[n];
|
||||
|
||||
let strVendor = OneModel['vendor'];
|
||||
let search = (OneModel['name'] + '\0' + strVendor).toLowerCase();
|
||||
|
||||
if (!kwSplit.every(s => search.includes(s)))
|
||||
continue;
|
||||
|
||||
//Add Vendor Html Node
|
||||
if ($(".OneVendorBlock[vendor='" + strVendor + "']").length == 0) {
|
||||
let sVV = strVendor;
|
||||
if (sVV == "BBL")
|
||||
sVV = "Bambu Lab";
|
||||
if (sVV == "Custom")
|
||||
sVV = "Custom Printer";
|
||||
if (sVV == "Other")
|
||||
sVV = "Orca colosseum";
|
||||
|
||||
let HtmlNewVendor = '<div class="OneVendorBlock" Vendor="' + strVendor + '">' +
|
||||
'<div class="BlockBanner">' +
|
||||
' <a>' + sVV + '</a>' +
|
||||
' <div class="BannerBtns" onClick="ChooseVendor('+"\'"+strVendor+"\'"+')">'+
|
||||
' <div class="modelCount"></div>' +
|
||||
' <input type="checkbox" class="VendorCheckbox"/>'+
|
||||
' </div>'+
|
||||
//' <div class="BannerBtns">' +
|
||||
//' <div class="ButtonStyleConfirm ButtonTypeWindow trans" tid="t11" onClick="SelectPrinterAll(' + "\'" + strVendor + "\'" + ')">all</div>' +
|
||||
//' <div class="ButtonStyleRegular ButtonTypeWindow trans" tid="t12" onClick="SelectPrinterNone(' + "\'" + strVendor + "\'" + ')">none</div>' +
|
||||
//' </div>' +
|
||||
'</div>' +
|
||||
'<div class="PrinterArea"> ' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$('#Content').append(HtmlNewVendor);
|
||||
}
|
||||
|
||||
//Collect Html Node Nozzel Html
|
||||
if (!ModelHtml.hasOwnProperty(strVendor))
|
||||
ModelHtml[strVendor] = '';
|
||||
|
||||
ModelHtml[strVendor]+=CreatePrinterBlock(OneModel); // ORCA
|
||||
}
|
||||
|
||||
//Update Nozzel Html Append
|
||||
for (let key in ModelHtml) {
|
||||
let obj = $(".OneVendorBlock[vendor='" + key + "'] .PrinterArea");
|
||||
obj.empty();
|
||||
obj.append(ModelHtml[key]);
|
||||
}
|
||||
|
||||
|
||||
//Update Checkbox
|
||||
ModelSelect = $('.ModelCheckBox');
|
||||
for (let n = 0; n < ModelSelect.length; n++) {
|
||||
let OneItem = ModelSelect[n];
|
||||
|
||||
let strModel = OneItem.getAttribute("model");
|
||||
let strVendor = OneItem.getAttribute("vendor");
|
||||
|
||||
let checked = GetModelSelect(strVendor, strModel);
|
||||
|
||||
if (checked)
|
||||
$(OneItem).addClass('ModelCheckBoxSelected');
|
||||
else
|
||||
$(OneItem).removeClass('ModelCheckBoxSelected');
|
||||
}
|
||||
|
||||
const $SidebarVendors = $('#SidebarVendors');
|
||||
let SidebarHTML = "";
|
||||
$(`.OneVendorBlock`).each((i, el)=>{
|
||||
UpdateVendorCheckbox(el.getAttribute("vendor"));
|
||||
SidebarHTML +=`<div class="SidebarItem" onclick="scrollToVendor(this.textContent)">${el.getAttribute('vendor')}</div>`;
|
||||
});
|
||||
$SidebarVendors.html(SidebarHTML)
|
||||
|
||||
const $content = $('#Content');
|
||||
$content.css("padding-right", $content[0].scrollHeight > $content[0].clientHeight ? "10px" : "20px");
|
||||
|
||||
// let AlreadySelect=$(".ModelCheckBoxSelected");
|
||||
// let nSelect=AlreadySelect.length;
|
||||
// if(nSelect==0)
|
||||
// {
|
||||
// $("div.OneVendorBlock[vendor='"+BBL+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
// }
|
||||
|
||||
TranslatePage();
|
||||
}
|
||||
|
||||
function CreatePrinterBlock(OneModel)
|
||||
{
|
||||
// ORCA use single functuon to create blocks to simplify code
|
||||
let vendor = OneModel['vendor']
|
||||
vendorName = vendor=="BBL" ? "Bambu Lab" : vendor=="Custom" ? "Generic Printer" : vendor;
|
||||
|
||||
let modelName = OneModel['name'];
|
||||
// Most of it unneeded. this can be applied in profiles
|
||||
if( vendor=="Custom")
|
||||
modelName = modelName.split(" ")[1];
|
||||
// these uses different case in name; seckit, ratrig, blocks
|
||||
else if (modelName.toLowerCase().startsWith(vendorName.toLowerCase()))
|
||||
modelName = modelName.slice(vendorName.length);
|
||||
// these not matches. have to fix in profiles to reduce conditions in here;
|
||||
else if (vendor == "MagicMaker" && modelName.startsWith("MM"))
|
||||
modelName = modelName.slice(("MM").length);
|
||||
else if (vendor == "OrcaArena")
|
||||
modelName = modelName.slice(("Orca Arena").length);
|
||||
else if (vendor == "RolohaunDesign" && modelName.startsWith("Rolohaun"))
|
||||
modelName = modelName.slice(("Rolohaun").length);
|
||||
|
||||
return '<div class="PrinterBlock" onClick="ChooseModel(\''+vendor+'\',\''+OneModel['model']+'\')">'+
|
||||
'<div class="PImg">'+
|
||||
'<img class="ModelThumbnail" src="' + OneModel['cover'] + '" />'+
|
||||
'</div>'+
|
||||
'<div class="PrinterInfoMark">?</div>'+
|
||||
'<div class="PrinterInfo">'+
|
||||
//' <div class="title trans">Print volume</div>'+
|
||||
//' <div class="value">' + OneModel['printable_height'] + '</div>'+
|
||||
' <div class="title trans">Nozzle</div>'+
|
||||
' <div class="value">' + OneModel['nozzle_diameter'].replaceAll(";", " · ") + '</div>'+
|
||||
'</div>'+
|
||||
'<div style="display: flex;">'+
|
||||
' <div class="ModelCheckBox" vendor="' +vendor+ '" model="'+OneModel['model']+'"></div>'+
|
||||
' <div class="PName">'+ modelName +'</div>'+ // ><p>'+ vendorName +'</p>
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function SelectPrinterAll( sVendor )
|
||||
{
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").addClass('ModelCheckBoxSelected');
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").each(function() {
|
||||
let strModel = this.getAttribute("model");
|
||||
SetModelSelect(sVendor, strModel, true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function SelectPrinterNone( sVendor )
|
||||
{
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").removeClass('ModelCheckBoxSelected');
|
||||
$("div.OneVendorBlock[vendor='"+sVendor+"'] .ModelCheckBox").each(function() {
|
||||
let strModel = this.getAttribute("model");
|
||||
SetModelSelect(sVendor, strModel, false);
|
||||
});
|
||||
}
|
||||
|
||||
function ChooseVendor(sVendor) {
|
||||
const $cbs = $(`.OneVendorBlock[vendor='${sVendor}'] .ModelCheckBox`);
|
||||
const sel = $cbs.length && $cbs.not('.ModelCheckBoxSelected').length;
|
||||
|
||||
sel ? $cbs.addClass('ModelCheckBoxSelected')
|
||||
: $cbs.removeClass('ModelCheckBoxSelected');
|
||||
|
||||
$cbs.each((i, el)=>{SetModelSelect(sVendor, el.getAttribute('model'), sel)});
|
||||
}
|
||||
|
||||
function UpdateVendorCheckbox(sVendor) {
|
||||
const $vb = $(`.OneVendorBlock[vendor='${sVendor}']`);
|
||||
const $cbs = $vb.find(`.ModelCheckBox`);
|
||||
const $vcb = $vb.find(`.VendorCheckbox`);
|
||||
|
||||
const selCount = $cbs.filter('.ModelCheckBoxSelected').length;
|
||||
const allSel = selCount === $cbs.length && selCount > 0;
|
||||
const nonSel = selCount === 0;
|
||||
|
||||
$vcb.prop({checked: allSel , indeterminate: !allSel && !nonSel});
|
||||
|
||||
$vb.find(".modelCount").text(selCount + " / " + $cbs.length);
|
||||
}
|
||||
|
||||
function OnExitFilter() {
|
||||
|
||||
let nTotal = 0;
|
||||
let ModelAll = {};
|
||||
for (vendor in ModelNozzleSelected) {
|
||||
for (model in ModelNozzleSelected[vendor]) {
|
||||
if (!ModelNozzleSelected[vendor][model])
|
||||
continue;
|
||||
|
||||
if (!ModelAll.hasOwnProperty(model)) {
|
||||
//alert("ADD: "+strModel);
|
||||
|
||||
ModelAll[model] = {};
|
||||
|
||||
ModelAll[model]["model"] = model;
|
||||
}
|
||||
|
||||
nTotal++;
|
||||
}
|
||||
}
|
||||
|
||||
var tSend = {};
|
||||
tSend['sequence_id'] = Math.round(new Date() / 1000);
|
||||
tSend['command'] = "save_userguide_models";
|
||||
tSend['data'] = ModelAll;
|
||||
|
||||
SendWXMessage(JSON.stringify(tSend));
|
||||
|
||||
return nTotal;
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
function OnExit()
|
||||
{
|
||||
let ModelAll={};
|
||||
|
||||
let ModelSelect=$(".ModelCheckBoxSelected");
|
||||
let nTotal=ModelSelect.length;
|
||||
|
||||
if( nTotal==0 )
|
||||
{
|
||||
ShowNotice(1);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
for(let n=0;n<nTotal;n++)
|
||||
{
|
||||
let OneItem=ModelSelect[n];
|
||||
|
||||
let strModel=OneItem.getAttribute("model");
|
||||
|
||||
//alert(strModel+strVendor+strNozzel);
|
||||
|
||||
if(!ModelAll.hasOwnProperty(strModel))
|
||||
{
|
||||
//alert("ADD: "+strModel);
|
||||
|
||||
ModelAll[strModel]={};
|
||||
|
||||
ModelAll[strModel]["model"]=strModel;
|
||||
}
|
||||
}
|
||||
|
||||
var tSend={};
|
||||
tSend['sequence_id']=Math.round(new Date() / 1000);
|
||||
tSend['command']="save_userguide_models";
|
||||
tSend['data']=ModelAll;
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
|
||||
return nTotal;
|
||||
}
|
||||
|
||||
|
||||
function ShowNotice( nShow )
|
||||
{
|
||||
if(nShow==0)
|
||||
{
|
||||
$("#NoticeMask").hide();
|
||||
$("#NoticeBody").hide();
|
||||
}
|
||||
else
|
||||
{
|
||||
$("#NoticeMask").show();
|
||||
$("#NoticeBody").show();
|
||||
}
|
||||
}
|
||||
|
||||
function CancelSelect()
|
||||
{
|
||||
var tSend={};
|
||||
@@ -490,7 +20,6 @@ function CancelSelect()
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
|
||||
function ConfirmSelect()
|
||||
{
|
||||
let nChoose=OnExitFilter();
|
||||
@@ -516,7 +45,3 @@ function CreateNewPrinter()
|
||||
|
||||
SendWXMessage( JSON.stringify(tSend) );
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>引导_P21</title>
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css" /> <!-- ORCA One for all-->
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../21/common.css" /> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<link rel="stylesheet" type="text/css" href="24.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../css/dark.css" />
|
||||
<!-- <script type="text/javascript" src="test.js"></script> -->
|
||||
@@ -13,8 +14,9 @@
|
||||
<script type="text/javascript" src="../js/json2.js"></script>
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.js"></script>
|
||||
<!-- <script type="text/javascript" src="../js/common.js"></script> -->
|
||||
<script type="text/javascript" src="24.js"></script>
|
||||
<script type="text/javascript" src="../js/common.js"></script>
|
||||
<script type="text/javascript" src="../21/common.js"></script> <!-- ORCA use common sources for setup guide and standalone dialog -->
|
||||
<script type="text/javascript" src="24.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
<div id="Title">
|
||||
@@ -49,65 +51,33 @@
|
||||
|
||||
<div id="Content" class="thin-scroll">
|
||||
|
||||
<!--<div class="OneVendorBlock" Vendor="BBL">
|
||||
<div class="BlockBanner">
|
||||
<div class="BannerBtns">
|
||||
<div class="ButtonStyleConfirm ButtonTypeWindow" onClick="SelectPrinterAll('BBL')">所有</div>
|
||||
<div class="ButtonStyleRegular ButtonTypeWindow" onClick="SelectPrinterNone('BBL')">无</div>
|
||||
<!-- EXAMPLE GENERATED CODE BLOCK
|
||||
<div class="OneVendorBlock" Vendor="VendorName">
|
||||
<div class="BlockBanner">
|
||||
<a>VendorName</a>
|
||||
<div class="BannerBtns" onClick="ChooseVendor('VendorName')" >
|
||||
<div class="modelCount"></div>
|
||||
<input type="checkbox" class="VendorCheckbox" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a>BBL-3DP</a>
|
||||
</div>
|
||||
|
||||
<div class="PrinterArea">
|
||||
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg"><img src="p2.jpg" /></div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel"><input id="ZZ" type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.4" vendor="BBL" />0.4mm nozzle</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.1" vendor="BBL" />0.1mm nozzle</div>
|
||||
</div>
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg"><img src="p1.jpg" /></div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.4" vendor="BBL" />0.4mm nozzle</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.2" vendor="BBL" />0.2mm nozzle</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.1" vendor="BBL" />0.1mm nozzle</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="OneVendorBlock" Vendor="BAMBU">
|
||||
<div class="BlockBanner">
|
||||
<div class="BannerBtns">
|
||||
<div class="Banner-Btn-green" onClick="SelectPrinterAll('BAMBU')">所有</div>
|
||||
<div class="Banner-Btn" onClick="SelectPrinterNone('BAMBU')">无</div>
|
||||
<div class="PrinterArea">
|
||||
<div class="PrinterBlock" onClick="ChooseModel('VendorName','ModelName')" >
|
||||
<div class="PImg" >
|
||||
<img class="ModelThumbnail" src="CoverPath" />
|
||||
</div>
|
||||
<div class="PrinterInfoMark">?</div>
|
||||
<div class="PrinterInfo">
|
||||
<div class="title trans">Nozzle</div>
|
||||
<div class="value">nozzleInfo</div>
|
||||
</div>
|
||||
<div style="display: flex;">
|
||||
<div class="ModelCheckBox" vendor="' +vendor+ '" model="'+OneModel['model']+'"></div>
|
||||
<div class="PName">modelName</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a>BBL-3DP</a>
|
||||
</div>
|
||||
|
||||
<div class="PrinterArea">
|
||||
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg"><img src="p2.jpg" /></div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.4" vendor="BAMBU" />0.4mm nozzle</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V4NORMAL" nozzel="0.1" vendor="BAMBU" />0.1mm nozzle</div>
|
||||
</div>
|
||||
<div class="PrinterBlock">
|
||||
<div class="PImg"><img src="p1.jpg" /></div>
|
||||
<div class="PName">BBL-3DP-V4NORMAL</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.4" vendor="BAMBU" />0.4mm nozzle</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.2" vendor="BAMBU" />0.2mm nozzle</div>
|
||||
<div class="pNozzel"><input type="checkbox" model="BBL-3DP-V5NORMAL" nozzel="0.1" vendor="BAMBU" />0.1mm nozzle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>-->
|
||||
-->
|
||||
|
||||
</div>
|
||||
<div id="AcceptArea">
|
||||
@@ -126,215 +96,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
<script>
|
||||
const SearchBox = document.querySelector('.searchTerm');
|
||||
|
||||
document.onkeydown = function (event) {
|
||||
var e = event || window.event || arguments.callee.caller.arguments[0];
|
||||
|
||||
if (e.keyCode == 27)
|
||||
ClosePage();
|
||||
|
||||
// ORCA focus search bar on key input
|
||||
// SearchBox not in focus && writable character && non modifier
|
||||
if (document.activeElement != SearchBox && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
SearchBox.focus();
|
||||
}
|
||||
|
||||
// Close sidebar
|
||||
document.getElementById('SidebarContainer').setAttribute('open', '0')
|
||||
|
||||
//if (window.event) {
|
||||
// try { e.keyCode = 0; } catch (e) { }
|
||||
// e.returnValue = true;
|
||||
//}
|
||||
};
|
||||
let pModel = {};
|
||||
let ModelNozzleSelected = {};
|
||||
function textInput(obj) {
|
||||
FilterModelList(obj.value);
|
||||
}
|
||||
|
||||
const $content = $('#Content');
|
||||
|
||||
// SNAPPY SCROLLING WITHOUT LAGS
|
||||
const SNAP_DELAY = 600;
|
||||
const SNAP_DURATION = 200;
|
||||
const SNAP_CORR = 8; // error correction / tolerance
|
||||
|
||||
let scrollTimer = null;
|
||||
let lastScrollTop = 0;
|
||||
let scrollDir = 'down';
|
||||
let isSnapping = false;
|
||||
let snapRafId = null;
|
||||
let lastSnapTarget = null;
|
||||
let waitingForUserScroll = false;
|
||||
|
||||
function findSnap(cur, dir) {
|
||||
if (lastSnapTarget !== null && Math.abs(cur - lastSnapTarget) < SNAP_CORR) return null;
|
||||
|
||||
const savedScroll = cur;
|
||||
|
||||
$content[0].scrollTop = 0; // Temporarily scroll to 0 so getBoundingClientRect can get absolute positions
|
||||
|
||||
let bcTop = el=>(el.getBoundingClientRect().top);
|
||||
|
||||
const contentTop = bcTop($content[0]);
|
||||
const bannerH = ($content.find('.BlockBanner')[0] || {}).offsetHeight || 0;
|
||||
|
||||
const firstCard = $content.find('.PrinterBlock')[0];
|
||||
const firstArea = $content.find('.PrinterArea')[0];
|
||||
const cardGap = (firstCard && firstArea) ? (bcTop(firstCard) - bcTop(firstArea)) : 0;
|
||||
|
||||
const candidates = $content.find('.BlockBanner, .PrinterBlock').get();
|
||||
if (dir === 'up') candidates.reverse();
|
||||
|
||||
let result = lastSeen = null;
|
||||
|
||||
for (const el of candidates) {
|
||||
const snapTo = Math.round(
|
||||
el.classList.contains('BlockBanner')
|
||||
? (bcTop(el.closest('.OneVendorBlock')) - contentTop)
|
||||
: Math.max(0, bcTop(el) - contentTop - bannerH - cardGap)
|
||||
);
|
||||
if (snapTo != lastSeen){
|
||||
lastSeen = snapTo;
|
||||
if (dir === 'down' && snapTo > cur + SNAP_CORR) { result = snapTo; break; }
|
||||
if (dir === 'up' && snapTo < cur - SNAP_CORR) { result = snapTo; break; }
|
||||
}
|
||||
}
|
||||
|
||||
$content[0].scrollTop = savedScroll; // Restore scroll position
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function smoothScrollTo(target) {
|
||||
if (snapRafId) {
|
||||
cancelAnimationFrame(snapRafId);
|
||||
snapRafId = null;
|
||||
}
|
||||
|
||||
const el = $content[0];
|
||||
const from = el.scrollTop;
|
||||
const dist = target - from;
|
||||
const t0 = performance.now();
|
||||
const ease = t => t < 0.5 ? 2*t*t : -1 + (4 - 2*t)*t;
|
||||
|
||||
function onDone() {
|
||||
el.scrollTop = target;
|
||||
lastScrollTop = lastSnapTarget = target;
|
||||
waitingForUserScroll = true;
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = null;
|
||||
snapRafId = null;
|
||||
isSnapping = false;
|
||||
}
|
||||
|
||||
if (Math.abs(dist) < 2)
|
||||
return onDone();
|
||||
|
||||
snapRafId = requestAnimationFrame(function step(now) {
|
||||
const p = Math.min((now - t0) / SNAP_DURATION, 1);
|
||||
el.scrollTop = from + dist * ease(p);
|
||||
if (p < 1)
|
||||
snapRafId = requestAnimationFrame(step);
|
||||
else
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
function armSnap() {
|
||||
waitingForUserScroll = false;
|
||||
lastSnapTarget = null;
|
||||
}
|
||||
|
||||
$content.on('scroll', function() {
|
||||
if (isSnapping) return;
|
||||
|
||||
if (this.scrollTop > lastScrollTop + 1) scrollDir = 'down';
|
||||
else if (this.scrollTop < lastScrollTop - 1) scrollDir = 'up';
|
||||
lastScrollTop = this.scrollTop;
|
||||
|
||||
if (waitingForUserScroll) return;
|
||||
|
||||
clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(()=>{
|
||||
if (isSnapping) return;
|
||||
|
||||
const target = findSnap($content[0].scrollTop, scrollDir);
|
||||
if (target){
|
||||
isSnapping = true;
|
||||
smoothScrollTo(target);
|
||||
}
|
||||
}, SNAP_DELAY);
|
||||
});
|
||||
|
||||
let touchY = 0;
|
||||
$content[0].addEventListener('touchstart', e => {
|
||||
touchY = e.touches[0].clientY;
|
||||
armSnap();
|
||||
}, { passive: true });
|
||||
|
||||
$content[0].addEventListener('touchmove', e => {
|
||||
const dy = touchY - e.touches[0].clientY;
|
||||
if (Math.abs(dy) > 3)
|
||||
scrollDir = dy > 0 ? 'down' : 'up';
|
||||
}, { passive: true });
|
||||
|
||||
// Re-arm snap system on user scroll
|
||||
$content[0].addEventListener('wheel', armSnap, { passive: true });
|
||||
|
||||
// Re-arm on after scrollbar usage
|
||||
$content[0].addEventListener('pointerdown', e => {
|
||||
if (e.target === $content[0])
|
||||
armSnap();
|
||||
});
|
||||
|
||||
// Re-arm on keyboard scroll or focus changes
|
||||
document.addEventListener('keydown', e => {
|
||||
if (document.activeElement != SearchBox){
|
||||
let scrollKeys = ['ArrowUp','ArrowDown','PageUp','PageDown',' '];
|
||||
let hasFocus = $content[0].contains(document.activeElement);
|
||||
if(scrollKeys.includes(e.key) || (hasFocus && e.which == 9))
|
||||
armSnap();
|
||||
}
|
||||
});
|
||||
|
||||
// ORCA unfocus search bar while scrolling and its content empty
|
||||
$content[0].addEventListener("scroll", () => {
|
||||
if (document.activeElement === SearchBox && SearchBox.value == "")
|
||||
SearchBox.blur();
|
||||
});
|
||||
|
||||
// LAYOUT SELECTOR
|
||||
const LayoutSelector = document.querySelector('.LayoutSelector > .TabGroup');
|
||||
const LayoutBtns = Array.from(LayoutSelector.children);
|
||||
const LayoutTypes = ["compact-list","compact-cover","large-cover"];
|
||||
|
||||
function LayoutMode(value) {
|
||||
if($content[0].getAttribute("layout") === value)
|
||||
return;
|
||||
|
||||
// find current visible vendor and scroll to it after layout change
|
||||
let target = null;
|
||||
for (const el of $content.find('.OneVendorBlock')) {
|
||||
if (el.getBoundingClientRect().bottom - $content[0].getBoundingClientRect().top >= -1) {
|
||||
target = el.getAttribute("vendor");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LayoutBtns.forEach(el => el.classList.remove('selected'));
|
||||
LayoutBtns[LayoutTypes.indexOf(value)].classList.add('selected');
|
||||
$content[0].setAttribute("layout", value);
|
||||
|
||||
if (target) scrollToVendor(target);
|
||||
}
|
||||
|
||||
LayoutMode("large-cover");
|
||||
initKeyEvents(true); // close on ESC
|
||||
InitGlobalVariables();
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -1339,6 +1339,16 @@ int CLI::run(int argc, char **argv)
|
||||
//BBS: remove GCodeViewer as separate APP logic
|
||||
//params.start_as_gcodeviewer = start_as_gcodeviewer;
|
||||
|
||||
// UI automation server (opt-in). --automation-server enables it;
|
||||
// --automation-server-port overrides the default 13619.
|
||||
ConfigOptionBool* automation_server_option = m_config.option<ConfigOptionBool>("automation_server");
|
||||
if (automation_server_option && automation_server_option->value) {
|
||||
ConfigOptionInt* automation_port_option = m_config.option<ConfigOptionInt>("automation_server_port");
|
||||
int port = (automation_port_option && automation_port_option->value > 0) ? automation_port_option->value : 13619;
|
||||
params.automation_port = port;
|
||||
BOOST_LOG_TRIVIAL(warning) << "UI automation server requested on port " << params.automation_port;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "begin to launch OrcaSlicer GUI soon";
|
||||
return Slic3r::GUI::GUI_Run(params);
|
||||
#else // SLIC3R_GUI
|
||||
|
||||
@@ -6174,7 +6174,9 @@ std::string GCode::extrude_support(const ExtrusionEntityCollection &support_fill
|
||||
if (extrusions.empty())
|
||||
return gcode;
|
||||
|
||||
chain_and_reorder_extrusion_entities(extrusions, m_last_pos.to_point());
|
||||
//ORCA: Respect no_sort to preserve support base outline->fill order.
|
||||
if (!support_fills.no_sort)
|
||||
chain_and_reorder_extrusion_entities(extrusions, m_last_pos.to_point());
|
||||
|
||||
const double support_speed = m_config.support_speed.value;
|
||||
const double support_interface_speed = m_config.get_abs_value("support_interface_speed");
|
||||
|
||||
@@ -1727,9 +1727,12 @@ void PerimeterGenerator::add_infill_contour_for_arachne( ExPolygons infil
|
||||
// Orca: sacrificial bridge layer algorithm ported from SuperSlicer
|
||||
void PerimeterGenerator::process_no_bridge(Surfaces& all_surfaces, coord_t perimeter_spacing, coord_t ext_perimeter_width)
|
||||
{
|
||||
|
||||
if (this->config->counterbore_hole_bridging == chbNone)
|
||||
return; // return if counterbore hole is not enabled
|
||||
|
||||
//store surface for bridge infill to avoid unsupported perimeters (but the first one, this one is always good)
|
||||
if (this->config->counterbore_hole_bridging != chbNone
|
||||
&& this->lower_slices != NULL && !this->lower_slices->empty()) {
|
||||
if (this->lower_slices != NULL && !this->lower_slices->empty()) {
|
||||
const coordf_t bridged_infill_margin = scale_(BRIDGE_INFILL_MARGIN);
|
||||
|
||||
for (size_t surface_idx = 0; surface_idx < all_surfaces.size(); surface_idx++) {
|
||||
@@ -1738,11 +1741,8 @@ void PerimeterGenerator::process_no_bridge(Surfaces& all_surfaces, coord_t perim
|
||||
//compute our unsupported surface
|
||||
ExPolygons unsupported = diff_ex(last, *this->lower_slices, ApplySafetyOffset::Yes);
|
||||
if (!unsupported.empty()) {
|
||||
// remove small overhangs (when using chbFilled we need to be less aggressive in removing small overhangs,
|
||||
// to avoid affecting bridging detection.)
|
||||
const int outset_divisor = this->config->counterbore_hole_bridging.value == chbFilled ? 2 : 1;
|
||||
ExPolygons unsupported_filtered = offset2_ex(unsupported, double(-perimeter_spacing),
|
||||
double(perimeter_spacing) / outset_divisor);
|
||||
//remove small overhangs
|
||||
ExPolygons unsupported_filtered = offset2_ex(unsupported, double(-perimeter_spacing), double(perimeter_spacing));
|
||||
|
||||
if (!unsupported_filtered.empty()) {
|
||||
//to_draw.insert(to_draw.end(), last.begin(), last.end());
|
||||
@@ -1759,13 +1759,18 @@ void PerimeterGenerator::process_no_bridge(Surfaces& all_surfaces, coord_t perim
|
||||
for (ExPolygon unsupported : unsupported_filtered) {
|
||||
BridgeDetector detector{ unsupported,
|
||||
lower_island.expolygons,
|
||||
perimeter_spacing };
|
||||
perimeter_spacing / 4}; // Use a finer BridgeDetector. This affects coverage resolution, not extrusion spacing.
|
||||
|
||||
if (detector.detect_angle(Geometry::deg2rad(this->config->bridge_angle.value)))
|
||||
expolygons_append(bridgeable, union_ex(detector.coverage(-1, true)));
|
||||
}
|
||||
if (!bridgeable.empty()) {
|
||||
//check if we get everything or just the bridgeable area
|
||||
if (/*this->config->counterbore_hole_bridging.value == chbNoPeri || */this->config->counterbore_hole_bridging.value == chbFilled) {
|
||||
if (!bridgeable.empty() && !surface->expolygon.holes.empty()) { // keep out if cannot be bridged or no holes to bridge
|
||||
const coordf_t bridge_anchor_offset = std::min({bridged_infill_margin, coordf_t(perimeter_spacing), coordf_t(ext_perimeter_width)});
|
||||
|
||||
// Handle filled vs partial counterbore bridging modes.
|
||||
if (this->config->counterbore_hole_bridging.value == chbFilled) {
|
||||
unsupported_filtered = offset_ex(unsupported_filtered, -perimeter_spacing); // shrink it to survive the strict bridge-candidate filter
|
||||
|
||||
//we bridge everything, even the not-bridgeable bits
|
||||
for (size_t i = 0; i < unsupported_filtered.size();) {
|
||||
ExPolygon& poly_unsupp = *(unsupported_filtered.begin() + i);
|
||||
@@ -1785,139 +1790,96 @@ void PerimeterGenerator::process_no_bridge(Surfaces& all_surfaces, coord_t perim
|
||||
unsupported_filtered.erase(unsupported_filtered.begin() + i);
|
||||
}
|
||||
}
|
||||
unsupported_filtered = intersection_ex(last,
|
||||
offset_ex(unsupported_filtered, 0.5 * double(bridged_infill_margin)));
|
||||
if (this->config->counterbore_hole_bridging.value == chbFilled) {
|
||||
for (ExPolygon& expol : unsupported_filtered) {
|
||||
//check if the holes won't be covered by the upper layer
|
||||
//TODO: if we want to do that, we must modify the geometry before making perimeters.
|
||||
//if (this->upper_slices != nullptr && !this->upper_slices->expolygons.empty()) {
|
||||
// for (Polygon &poly : expol.holes) poly.make_counter_clockwise();
|
||||
// float perimeterwidth = this->config->perimeters == 0 ? 0 : (this->ext_perimeter_flow.scaled_width() + (this->config->perimeters - 1) + this->perimeter_flow.scaled_spacing());
|
||||
// std::cout << "test upper slices with perimeterwidth=" << perimeterwidth << "=>" << offset_ex(this->upper_slices->expolygons, -perimeterwidth).size();
|
||||
// if (intersection(Polygons() = { expol.holes }, to_polygons(offset_ex(this->upper_slices->expolygons, -this->ext_perimeter_flow.scaled_width() / 2))).empty()) {
|
||||
// std::cout << " EMPTY";
|
||||
// expol.holes.clear();
|
||||
// } else {
|
||||
// }
|
||||
// std::cout << "\n";
|
||||
//} else {
|
||||
expol.holes.clear();
|
||||
//}
|
||||
|
||||
//detect inside volume
|
||||
for (size_t surface_idx_other = 0; surface_idx_other < all_surfaces.size(); surface_idx_other++) {
|
||||
if (surface_idx == surface_idx_other) continue;
|
||||
if (intersection_ex(ExPolygons() = { expol }, ExPolygons() = { all_surfaces[surface_idx_other].expolygon }).size() > 0) {
|
||||
//this means that other_surf was inside an expol holes
|
||||
//as we removed them, we need to add a new one
|
||||
ExPolygons new_poly = offset2_ex(ExPolygons{ all_surfaces[surface_idx_other].expolygon }, double(-bridged_infill_margin - perimeter_spacing), double(perimeter_spacing));
|
||||
if (new_poly.size() == 1) {
|
||||
all_surfaces[surface_idx_other].expolygon = new_poly[0];
|
||||
expol.holes.push_back(new_poly[0].contour);
|
||||
unsupported_filtered = offset_ex(unsupported_filtered, perimeter_spacing + bridge_anchor_offset); // restore it back to its original size and add anchor
|
||||
unsupported_filtered = intersection_ex(last, unsupported_filtered); // clamp to the original surface, to avoid creating new unsupported areas
|
||||
|
||||
for (ExPolygon& expol : unsupported_filtered) {
|
||||
// Remove holes that need sacrificial fill, but keep holes
|
||||
// whose wall is already supported by the lower layer.
|
||||
const float hole_wall_width = float(ext_perimeter_width / 2);
|
||||
for (size_t hole_idx = 0; hole_idx < expol.holes.size();) {
|
||||
Polygon hole_area_contour = expol.holes[hole_idx];
|
||||
hole_area_contour.make_counter_clockwise();
|
||||
|
||||
const ExPolygons hole_area = { ExPolygon(hole_area_contour) };
|
||||
ExPolygons hole_wall_area = diff_ex(
|
||||
offset_ex(hole_area_contour, hole_wall_width),
|
||||
hole_area,
|
||||
ApplySafetyOffset::Yes);
|
||||
hole_wall_area = intersection_ex(hole_wall_area, ExPolygons{ expol }, ApplySafetyOffset::Yes);
|
||||
if (!hole_wall_area.empty() &&
|
||||
intersection_ex(hole_wall_area, *this->lower_slices, ApplySafetyOffset::Yes).empty())
|
||||
expol.holes.erase(expol.holes.begin() + hole_idx);
|
||||
// After erase(), the next hole shifts into the same index. So hole_idx
|
||||
// must not be incremented, otherwise the next hole would be skipped.
|
||||
else
|
||||
++hole_idx; // keep this hole, it won't be bridged, so we need to keep it as a hole
|
||||
}
|
||||
|
||||
//detect inside volume
|
||||
for (size_t surface_idx_other = 0; surface_idx_other < all_surfaces.size(); surface_idx_other++) {
|
||||
if (surface_idx == surface_idx_other) continue;
|
||||
if (intersection_ex(ExPolygons() = { expol }, ExPolygons() = { all_surfaces[surface_idx_other].expolygon }).size() > 0) {
|
||||
//this means that other_surf was inside an expol holes
|
||||
//as we removed them, we need to add a new one
|
||||
ExPolygons new_poly = offset2_ex(ExPolygons{ all_surfaces[surface_idx_other].expolygon }, double(-bridged_infill_margin - perimeter_spacing), double(perimeter_spacing));
|
||||
if (new_poly.size() == 1) {
|
||||
all_surfaces[surface_idx_other].expolygon = new_poly[0];
|
||||
expol.holes.push_back(new_poly[0].contour);
|
||||
expol.holes.back().make_clockwise();
|
||||
} else {
|
||||
for (size_t idx = 0; idx < new_poly.size(); idx++) {
|
||||
Surface new_surf = all_surfaces[surface_idx_other];
|
||||
new_surf.expolygon = new_poly[idx];
|
||||
all_surfaces.push_back(new_surf);
|
||||
expol.holes.push_back(new_poly[idx].contour);
|
||||
expol.holes.back().make_clockwise();
|
||||
} else {
|
||||
for (size_t idx = 0; idx < new_poly.size(); idx++) {
|
||||
Surface new_surf = all_surfaces[surface_idx_other];
|
||||
new_surf.expolygon = new_poly[idx];
|
||||
all_surfaces.push_back(new_surf);
|
||||
expol.holes.push_back(new_poly[idx].contour);
|
||||
expol.holes.back().make_clockwise();
|
||||
}
|
||||
all_surfaces.erase(all_surfaces.begin() + surface_idx_other);
|
||||
if (surface_idx_other < surface_idx) {
|
||||
surface_idx--;
|
||||
surface = &all_surfaces[surface_idx];
|
||||
}
|
||||
surface_idx_other--;
|
||||
}
|
||||
all_surfaces.erase(all_surfaces.begin() + surface_idx_other);
|
||||
if (surface_idx_other < surface_idx) {
|
||||
surface_idx--;
|
||||
surface = &all_surfaces[surface_idx];
|
||||
}
|
||||
surface_idx_other--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
//TODO: add other polys as holes inside this one (-margin)
|
||||
} else if (/*this->config->counterbore_hole_bridging.value == chbBridgesOverhangs || */this->config->counterbore_hole_bridging.value == chbBridges) {
|
||||
// Partially bridged counterbore handling should not rewrite generic bridge islands
|
||||
// because by doing so regular bridges will lose their overhang-wall perimeters.
|
||||
if (surface->expolygon.holes.empty()) {
|
||||
unsupported_filtered.clear(); // "Partially bridged" only applies to hole-bearing bridge islands.
|
||||
continue;
|
||||
}
|
||||
//simplify to avoid most of artefacts from printing lines.
|
||||
ExPolygons bridgeable_simplified;
|
||||
} else { // if(this->config->counterbore_hole_bridging.value == chbBridges)
|
||||
// Orca: Partial counterbore bridging is mask-based. Preserve the supported
|
||||
// remainder (`last`) and use simplified BridgeDetector coverage to derive the
|
||||
// bridgeable counterbore span. The span is grown from supported material,
|
||||
// shrunk back, stripped from `last`, and expanded back. It is then prevented
|
||||
// from intruding deeper into `last` than the explicit anchor overlap.
|
||||
// Finally, add the allowed anchor band from `last` then remove the
|
||||
// narrow hole-side wall contact, which must remain unbridgeable.
|
||||
|
||||
last = diff_ex(last, unsupported_filtered, ApplySafetyOffset::Yes);
|
||||
|
||||
ExPolygons bridgeable_filtered;
|
||||
for (ExPolygon& poly : bridgeable) {
|
||||
poly.simplify(perimeter_spacing, &bridgeable_simplified);
|
||||
poly.simplify(perimeter_spacing, &bridgeable_filtered);
|
||||
}
|
||||
bridgeable_simplified = offset2_ex(bridgeable_simplified, -ext_perimeter_width, ext_perimeter_width);
|
||||
//bridgeable_simplified = intersection_ex(bridgeable_simplified, unsupported_filtered);
|
||||
//offset by perimeter spacing because the simplify may have reduced it a bit.
|
||||
//it's not dangerous as it will be intersected by 'unsupported' later
|
||||
//FIXME: add overlap in this->fill_surfaces->append
|
||||
//FIXME: it overlap inside unsuppported not-bridgeable area!
|
||||
bridgeable_filtered = opening_ex(bridgeable_filtered, ext_perimeter_width);
|
||||
|
||||
//bridgeable_simplified = offset2_ex(bridgeable_simplified, (double)-perimeter_spacing, (double)perimeter_spacing * 2);
|
||||
//ExPolygons unbridgeable = offset_ex(diff_ex(unsupported, bridgeable_simplified), perimeter_spacing * 3 / 2);
|
||||
//ExPolygons unbridgeable = intersection_ex(unsupported, diff_ex(unsupported_filtered, offset_ex(bridgeable_simplified, ext_perimeter_width / 2)));
|
||||
//unbridgeable = offset2_ex(unbridgeable, -ext_perimeter_width, ext_perimeter_width);
|
||||
// Get rid of coarseness of the resulted bridgeable area by using the original supported area as reference.
|
||||
// This is to avoid keeping tiny bridgeable areas that are far from the supported area, or protrude into it.
|
||||
bridgeable_filtered = union_ex(offset_ex(last, perimeter_spacing), bridgeable_filtered);
|
||||
bridgeable_filtered = offset_ex(bridgeable_filtered, -perimeter_spacing);
|
||||
bridgeable_filtered = diff_ex(bridgeable_filtered, last, ApplySafetyOffset::Yes);
|
||||
bridgeable_filtered = opening_ex(bridgeable_filtered, perimeter_spacing); // filter noise from the diff_ex
|
||||
bridgeable_filtered = offset_ex(bridgeable_filtered, perimeter_spacing); // restore the size to the original bridgeable area
|
||||
// Safety measure: Keep the bridge mask from intruding deeper into the
|
||||
// supported anchor region (`last`) than the explicit anchor overlap.
|
||||
bridgeable_filtered = diff_ex(bridgeable_filtered, offset_ex(last, -bridge_anchor_offset));
|
||||
|
||||
|
||||
// if (this->config->counterbore_hole_bridging.value == chbBridges) {
|
||||
ExPolygons unbridgeable = unsupported_filtered;
|
||||
for (ExPolygon& expol : unbridgeable)
|
||||
expol.holes.clear();
|
||||
unbridgeable = diff_ex(unbridgeable, bridgeable_simplified);
|
||||
unbridgeable = offset2_ex(unbridgeable, -ext_perimeter_width * 2, ext_perimeter_width * 2);
|
||||
ExPolygons bridges_temp = offset2_ex(intersection_ex(last, diff_ex(unsupported_filtered, unbridgeable), ApplySafetyOffset::Yes), -ext_perimeter_width / 4, ext_perimeter_width / 4);
|
||||
//remove the overhangs section from the surface polygons
|
||||
ExPolygons reference = last;
|
||||
last = diff_ex(last, unsupported_filtered);
|
||||
//ExPolygons no_bridge = diff_ex(offset_ex(unbridgeable, ext_perimeter_width * 3 / 2), last);
|
||||
//bridges_temp = diff_ex(bridges_temp, no_bridge);
|
||||
coordf_t offset_to_do = bridged_infill_margin;
|
||||
bool first = true;
|
||||
unbridgeable = diff_ex(unbridgeable, offset_ex(bridges_temp, ext_perimeter_width));
|
||||
while (offset_to_do > ext_perimeter_width * 1.5) {
|
||||
unbridgeable = offset2_ex(unbridgeable, -ext_perimeter_width / 4, ext_perimeter_width * 2.25, ClipperLib::jtSquare);
|
||||
bridges_temp = diff_ex(bridges_temp, unbridgeable);
|
||||
bridges_temp = offset_ex(bridges_temp, ext_perimeter_width, ClipperLib::jtMiter, 6.);
|
||||
unbridgeable = diff_ex(unbridgeable, offset_ex(bridges_temp, ext_perimeter_width));
|
||||
offset_to_do -= ext_perimeter_width;
|
||||
first = false;
|
||||
}
|
||||
unbridgeable = offset_ex(unbridgeable, ext_perimeter_width + offset_to_do, ClipperLib::jtSquare);
|
||||
bridges_temp = diff_ex(bridges_temp, unbridgeable);
|
||||
unsupported_filtered = offset_ex(bridges_temp, offset_to_do);
|
||||
unsupported_filtered = intersection_ex(unsupported_filtered, reference);
|
||||
|
||||
// Normalize anchor size for partial bridges:
|
||||
// derive the bridge core first, then add a fixed overlap into support.
|
||||
const coordf_t anchor_overlap = bridged_infill_margin;
|
||||
ExPolygons bridge_core = diff_ex(unsupported_filtered, support, ApplySafetyOffset::Yes);
|
||||
if (bridge_core.empty()) {
|
||||
bridge_core = unsupported_filtered;
|
||||
}
|
||||
ExPolygons anchor_overlap_area = intersection_ex(
|
||||
offset_ex(bridge_core, anchor_overlap),
|
||||
support,
|
||||
ApplySafetyOffset::Yes);
|
||||
unsupported_filtered = union_ex(bridge_core, anchor_overlap_area);
|
||||
unsupported_filtered = intersection_ex(unsupported_filtered, reference);
|
||||
// } else {
|
||||
// ExPolygons unbridgeable = intersection_ex(unsupported, diff_ex(unsupported_filtered, offset_ex(bridgeable_simplified, ext_perimeter_width / 2)));
|
||||
// unbridgeable = offset2_ex(unbridgeable, -ext_perimeter_width, ext_perimeter_width);
|
||||
// unsupported_filtered = unbridgeable;
|
||||
|
||||
// ////put the bridge area inside the unsupported_filtered variable
|
||||
// //unsupported_filtered = intersection_ex(last,
|
||||
// // diff_ex(
|
||||
// // offset_ex(bridgeable_simplified, (double)perimeter_spacing / 2),
|
||||
// // unbridgeable
|
||||
// // )
|
||||
// // );
|
||||
// }
|
||||
} else {
|
||||
unsupported_filtered.clear();
|
||||
ExPolygons bridge_anchor_areas = intersection_ex(last, offset_ex(unsupported_filtered, bridge_anchor_offset));
|
||||
unsupported_filtered = union_ex(bridgeable_filtered, bridge_anchor_areas); // add bridge anchor
|
||||
unsupported_filtered = opening_ex(unsupported_filtered, bridge_anchor_offset); // remove anchor area from hole-side walls, it must remain unbridgeable
|
||||
// TODO: Fix the case with thin outer walls around the bridge (1~2 walls) where classic wall
|
||||
// might generate two walls in a tiny space or non at all if "Detect thin walls" is not activated
|
||||
}
|
||||
} else {
|
||||
unsupported_filtered.clear();
|
||||
|
||||
@@ -10791,6 +10791,19 @@ CLIMiscConfigDef::CLIMiscConfigDef()
|
||||
def->tooltip = L("If enabled, this slicing will be considered using timelapse.");
|
||||
def->set_default_value(new ConfigOptionBool(false));
|
||||
|
||||
def = this->add("automation_server", coBool);
|
||||
def->label = L("Enable UI automation server");
|
||||
def->tooltip = L("Start a localhost JSON-RPC server that lets external scripts "
|
||||
"drive and observe the GUI. For testing/automation only.");
|
||||
def->set_default_value(new ConfigOptionBool(false));
|
||||
|
||||
def = this->add("automation_server_port", coInt);
|
||||
def->label = L("UI automation server port");
|
||||
def->tooltip = L("TCP port for the UI automation server (bound to 127.0.0.1).");
|
||||
def->min = 1;
|
||||
def->cli_params = "port";
|
||||
def->set_default_value(new ConfigOptionInt(13619));
|
||||
|
||||
#if (defined(_MSC_VER) || defined(__MINGW32__)) && defined(SLIC3R_GUI)
|
||||
/*def = this->add("sw_renderer", coBool);
|
||||
def->label = L("Render with a software renderer");
|
||||
|
||||
@@ -1610,80 +1610,71 @@ void TreeSupport::generate_toolpaths()
|
||||
filler_support->angle = Geometry::deg2rad(object_config.support_angle.value);
|
||||
|
||||
Polygons loops = to_polygons(poly);
|
||||
//ORCA: Group base per area as no_sort to keep outline->fill together.
|
||||
std::unique_ptr<ExtrusionEntityCollection> base_eec = std::make_unique<ExtrusionEntityCollection>();
|
||||
base_eec->no_sort = true;
|
||||
ExtrusionEntitiesPtr &base_dst = base_eec->entities;
|
||||
if (layer_id == 0) {
|
||||
float density = float(m_object_config->raft_first_layer_density.value * 0.01);
|
||||
fill_expolygons_with_sheath_generate_paths(ts_layer->support_fills.entities, loops, filler_support.get(), density, erSupportMaterial, flow,
|
||||
fill_expolygons_with_sheath_generate_paths(base_dst, loops, filler_support.get(), density, erSupportMaterial, flow,
|
||||
m_support_params, true, false);
|
||||
}
|
||||
else {
|
||||
//ORCA: Force base walls before infill to keep outline->fill order.
|
||||
if (need_infill && m_support_params.base_fill_pattern != ipLightning) {
|
||||
// allow infill-only mode if support is thick enough (so min_wall_count is 0);
|
||||
// otherwise must draw 1 wall
|
||||
// Don't need extra walls if we have infill. Extra walls may overlap with the infills.
|
||||
size_t min_wall_count = offset(poly, -scale_(support_spacing * 1.5)).empty() ? 1 : 0;
|
||||
make_perimeter_and_infill(ts_layer->support_fills.entities, poly, std::max(min_wall_count, wall_count), flow,
|
||||
erSupportMaterial, filler_support.get(), support_density);
|
||||
make_perimeter_and_infill(base_dst, poly, std::max(min_wall_count, wall_count), flow,
|
||||
erSupportMaterial, filler_support.get(), support_density, false);
|
||||
}
|
||||
else {
|
||||
SupportParameters support_params = m_support_params;
|
||||
if (area_group.need_extra_wall && object_config.tree_support_wall_count.value == 0)
|
||||
support_params.tree_branch_diameter_double_wall_area_scaled = 0.1;
|
||||
tree_supports_generate_paths(ts_layer->support_fills.entities, loops, flow, support_params);
|
||||
tree_supports_generate_paths(base_dst, loops, flow, support_params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m_support_params.base_fill_pattern == ipLightning)
|
||||
{
|
||||
double print_z = ts_layer->print_z;
|
||||
if (printZ_to_lightninglayer.find(print_z) == printZ_to_lightninglayer.end())
|
||||
continue;
|
||||
//TODO:
|
||||
//1.the second parameter of convertToLines seems to decide how long the lightning should be trimmed from its root, so that the root wont overlap/detach the support contour.
|
||||
// whether current value works correctly remained to be tested
|
||||
//2.related to previous one, that lightning roots need to be trimed more when support has multiple walls
|
||||
//3.function connect_infill() and variable 'params' helps create connection pattern along contours between two lightning roots,
|
||||
// strengthen lightnings while it may make support harder. decide to enable it or not. if yes, proper values for params are remained to be tested
|
||||
auto& lightning_layer = generator->getTreesForLayer(printZ_to_lightninglayer[print_z]);
|
||||
|
||||
Flow flow = (layer_id == 0 && m_raft_layers == 0) ? m_support_params.first_layer_flow : support_flow;
|
||||
ExPolygons areas = offset_ex(ts_layer->base_areas, -flow.scaled_spacing());
|
||||
|
||||
for (auto& area : areas)
|
||||
{
|
||||
Polylines polylines = lightning_layer.convertToLines(to_polygons(area), 0);
|
||||
for (auto itr = polylines.begin(); itr != polylines.end();)
|
||||
{
|
||||
if (itr->length() < scale_(1.0))
|
||||
itr = polylines.erase(itr);
|
||||
else
|
||||
itr++;
|
||||
}
|
||||
Polylines opt_polylines;
|
||||
#if 1
|
||||
//this wont create connection patterns along contours
|
||||
append(opt_polylines, chain_polylines(std::move(polylines)));
|
||||
#else
|
||||
//this will create connection patterns along contours
|
||||
FillParams params;
|
||||
params.anchor_length = float(Fill::infill_anchor * 0.01 * flow.spacing());
|
||||
params.anchor_length_max = Fill::infill_anchor_max;
|
||||
params.anchor_length = std::min(params.anchor_length, params.anchor_length_max);
|
||||
Fill::connect_infill(std::move(polylines), area, opt_polylines, flow.spacing(), params);
|
||||
#endif
|
||||
extrusion_entities_append_paths(ts_layer->support_fills.entities, opt_polylines, erSupportMaterial,
|
||||
float(flow.mm3_per_mm()), float(flow.width()), float(flow.height()));
|
||||
|
||||
//ORCA: Emit lightning infill per base area to avoid interleaving across islands.
|
||||
if (m_support_params.base_fill_pattern == ipLightning) {
|
||||
double print_z = ts_layer->print_z;
|
||||
auto lightning_layer_mapping = printZ_to_lightninglayer.find(print_z);
|
||||
if (lightning_layer_mapping != printZ_to_lightninglayer.end()) {
|
||||
auto &lightning_layer = generator->getTreesForLayer(lightning_layer_mapping->second);
|
||||
ExPolygons areas;
|
||||
areas.emplace_back(poly);
|
||||
areas = offset_ex(areas, -flow.scaled_spacing());
|
||||
for (auto &area : areas) {
|
||||
Polylines polylines = lightning_layer.convertToLines(to_polygons(area), 0);
|
||||
for (auto itr = polylines.begin(); itr != polylines.end();) {
|
||||
if (itr->length() < scale_(1.0))
|
||||
itr = polylines.erase(itr);
|
||||
else
|
||||
itr++;
|
||||
}
|
||||
Polylines opt_polylines;
|
||||
append(opt_polylines, chain_polylines(std::move(polylines)));
|
||||
extrusion_entities_append_paths(base_dst, opt_polylines, erSupportMaterial,
|
||||
float(flow.mm3_per_mm()), float(flow.width()), float(flow.height()));
|
||||
#ifdef SUPPORT_TREE_DEBUG_TO_SVG
|
||||
std::string name = debug_out_path("trees_polyline_%.2f.svg", ts_layer->print_z);
|
||||
BoundingBox bbox = get_extents(ts_layer->base_areas);
|
||||
SVG svg(name, bbox);
|
||||
if (svg.is_opened()) {
|
||||
svg.draw(ts_layer->base_areas, "blue");
|
||||
svg.draw(generator->Overhangs()[printZ_to_lightninglayer[print_z]], "red");
|
||||
for (auto &line : opt_polylines) svg.draw(line, "yellow");
|
||||
}
|
||||
std::string name = debug_out_path("trees_polyline_%.2f.svg", ts_layer->print_z);
|
||||
BoundingBox bbox = get_extents(ts_layer->base_areas);
|
||||
SVG svg(name, bbox);
|
||||
if (svg.is_opened()) {
|
||||
svg.draw(ts_layer->base_areas, "blue");
|
||||
svg.draw(generator->Overhangs()[lightning_layer_mapping->second], "red");
|
||||
for (auto &line : opt_polylines) svg.draw(line, "yellow");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//ORCA: Keep per-area base paths grouped for outline->fill preservation.
|
||||
if (!base_eec->empty())
|
||||
ts_layer->support_fills.entities.push_back(base_eec.release());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,21 @@ set(SLIC3R_GUI_SOURCES
|
||||
GUI/HMSPanel.hpp
|
||||
GUI/HttpServer.cpp
|
||||
GUI/HttpServer.hpp
|
||||
GUI/Automation/IUiBackend.hpp
|
||||
GUI/Automation/WidgetSerializer.cpp
|
||||
GUI/Automation/WidgetSerializer.hpp
|
||||
GUI/Automation/Locator.cpp
|
||||
GUI/Automation/Locator.hpp
|
||||
GUI/Automation/JsonRpcDispatcher.cpp
|
||||
GUI/Automation/JsonRpcDispatcher.hpp
|
||||
GUI/Automation/AutomationServer.cpp
|
||||
GUI/Automation/AutomationServer.hpp
|
||||
GUI/Automation/AutomationRegistry.cpp
|
||||
GUI/Automation/AutomationRegistry.hpp
|
||||
GUI/Automation/ImGuiItemTable.cpp
|
||||
GUI/Automation/ImGuiItemTable.hpp
|
||||
GUI/Automation/WxUiBackend.cpp
|
||||
GUI/Automation/WxUiBackend.hpp
|
||||
GUI/I18N.cpp
|
||||
GUI/I18N.hpp
|
||||
GUI/DragDropPanel.cpp
|
||||
|
||||
50
src/slic3r/GUI/Automation/AutomationRegistry.cpp
Normal file
50
src/slic3r/GUI/Automation/AutomationRegistry.cpp
Normal file
@@ -0,0 +1,50 @@
|
||||
#include "AutomationRegistry.hpp"
|
||||
#include <wx/window.h>
|
||||
#include <wx/event.h>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
namespace {
|
||||
std::mutex& mtx() { static std::mutex m; return m; }
|
||||
std::unordered_map<const wxWindow*, std::string>& fwd() {
|
||||
static std::unordered_map<const wxWindow*, std::string> m; return m;
|
||||
}
|
||||
std::unordered_map<std::string, wxWindow*>& rev() {
|
||||
static std::unordered_map<std::string, wxWindow*> m; return m;
|
||||
}
|
||||
void erase_window(const wxWindow* w) {
|
||||
std::lock_guard<std::mutex> lk(mtx());
|
||||
auto it = fwd().find(w);
|
||||
if (it != fwd().end()) { rev().erase(it->second); fwd().erase(it); }
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void set_automation_id(wxWindow* window, const std::string& id) {
|
||||
if (window == nullptr || id.empty()) return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mtx());
|
||||
fwd()[window] = id;
|
||||
rev()[id] = window;
|
||||
}
|
||||
// Prune on destruction.
|
||||
window->Bind(wxEVT_DESTROY, [window](wxWindowDestroyEvent& e) {
|
||||
erase_window(window);
|
||||
e.Skip();
|
||||
});
|
||||
}
|
||||
|
||||
std::string automation_id_of(const wxWindow* window) {
|
||||
std::lock_guard<std::mutex> lk(mtx());
|
||||
auto it = fwd().find(window);
|
||||
return it == fwd().end() ? std::string() : it->second;
|
||||
}
|
||||
|
||||
wxWindow* window_for_automation_id(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lk(mtx());
|
||||
auto it = rev().find(id);
|
||||
return it == rev().end() ? nullptr : it->second;
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
19
src/slic3r/GUI/Automation/AutomationRegistry.hpp
Normal file
19
src/slic3r/GUI/Automation/AutomationRegistry.hpp
Normal file
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
class wxWindow;
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// Process-wide wxWindow* <-> automation_id side map. Header is dependency-light so
|
||||
// widget-construction code can call set_automation_id() unconditionally — it is a
|
||||
// cheap, safe registration that no-ops when the window is null.
|
||||
//
|
||||
// Registration is pruned automatically when the window is destroyed (bound to
|
||||
// wxEVT_DESTROY inside set_automation_id).
|
||||
void set_automation_id(wxWindow* window, const std::string& id);
|
||||
std::string automation_id_of(const wxWindow* window); // "" if none
|
||||
wxWindow* window_for_automation_id(const std::string& id); // nullptr if none
|
||||
|
||||
}}} // namespace
|
||||
100
src/slic3r/GUI/Automation/AutomationServer.cpp
Normal file
100
src/slic3r/GUI/Automation/AutomationServer.cpp
Normal file
@@ -0,0 +1,100 @@
|
||||
#include "AutomationServer.hpp"
|
||||
#include "libslic3r/Thread.hpp" // create_thread / set_current_thread_name
|
||||
|
||||
#include <boost/beast/core.hpp>
|
||||
#include <boost/beast/http.hpp>
|
||||
#include <boost/beast/version.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
namespace beast = boost::beast;
|
||||
namespace http = beast::http;
|
||||
namespace net = boost::asio;
|
||||
using tcp = net::ip::tcp;
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
AutomationServer::AutomationServer(unsigned short port) : m_port(port) {}
|
||||
|
||||
AutomationServer::~AutomationServer() { stop(); }
|
||||
|
||||
void AutomationServer::start() {
|
||||
if (m_started) return;
|
||||
m_ioc = std::make_unique<net::io_context>(1);
|
||||
// Bind to loopback ONLY.
|
||||
tcp::endpoint endpoint(net::ip::make_address("127.0.0.1"), m_port);
|
||||
m_acceptor = std::make_unique<tcp::acceptor>(*m_ioc);
|
||||
m_acceptor->open(endpoint.protocol());
|
||||
m_acceptor->set_option(net::socket_base::reuse_address(true));
|
||||
m_acceptor->bind(endpoint);
|
||||
m_acceptor->listen(net::socket_base::max_listen_connections);
|
||||
m_started = true;
|
||||
|
||||
do_accept();
|
||||
|
||||
net::io_context* ioc = m_ioc.get();
|
||||
m_thread = create_thread([ioc] {
|
||||
set_current_thread_name("orca_automation");
|
||||
ioc->run();
|
||||
});
|
||||
BOOST_LOG_TRIVIAL(info) << "AutomationServer listening on 127.0.0.1:" << m_port;
|
||||
}
|
||||
|
||||
void AutomationServer::stop() {
|
||||
if (!m_started) return;
|
||||
m_started = false;
|
||||
if (m_ioc) m_ioc->stop();
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
m_acceptor.reset();
|
||||
m_ioc.reset();
|
||||
}
|
||||
|
||||
void AutomationServer::do_accept() {
|
||||
m_acceptor->async_accept([this](beast::error_code ec, tcp::socket socket) {
|
||||
if (!ec) {
|
||||
// v1: single-client, serialized — handle synchronously on the io thread.
|
||||
handle_session(std::move(socket));
|
||||
}
|
||||
if (m_started && m_acceptor && m_acceptor->is_open())
|
||||
do_accept();
|
||||
});
|
||||
}
|
||||
|
||||
void AutomationServer::handle_session(tcp::socket socket) {
|
||||
beast::error_code ec;
|
||||
beast::flat_buffer buffer;
|
||||
http::request<http::string_body> req;
|
||||
http::read(socket, buffer, req, ec);
|
||||
if (ec) { socket.shutdown(tcp::socket::shutdown_send, ec); return; }
|
||||
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
res.keep_alive(false);
|
||||
|
||||
if (req.method() == http::verb::post && req.target() == "/jsonrpc") {
|
||||
std::string body_out;
|
||||
try {
|
||||
body_out = m_handler ? m_handler(req.body())
|
||||
: R"({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"no handler"}})";
|
||||
} catch (const std::exception& e) {
|
||||
body_out = std::string(R"({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":")")
|
||||
+ e.what() + R"("}})";
|
||||
}
|
||||
res.result(http::status::ok);
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.body() = std::move(body_out);
|
||||
} else if (req.method() == http::verb::get && req.target() == "/") {
|
||||
res.result(http::status::ok);
|
||||
res.set(http::field::content_type, "text/plain");
|
||||
res.body() = m_health;
|
||||
} else {
|
||||
res.result(http::status::not_found);
|
||||
res.set(http::field::content_type, "text/plain");
|
||||
res.body() = "not found";
|
||||
}
|
||||
res.set(http::field::server, "OrcaSlicer/automation");
|
||||
res.prepare_payload();
|
||||
http::write(socket, res, ec);
|
||||
socket.shutdown(tcp::socket::shutdown_send, ec);
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
42
src/slic3r/GUI/Automation/AutomationServer.hpp
Normal file
42
src/slic3r/GUI/Automation/AutomationServer.hpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <boost/asio.hpp>
|
||||
#include <boost/thread.hpp>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// Localhost-only HTTP/1.1 server. POST /jsonrpc -> handler(body) -> response body.
|
||||
// GET / -> a tiny health/version page. The handler runs on the server's own
|
||||
// io thread; it is responsible for any further thread marshaling.
|
||||
class AutomationServer {
|
||||
public:
|
||||
using RequestHandler = std::function<std::string(const std::string& body)>;
|
||||
|
||||
explicit AutomationServer(unsigned short port);
|
||||
~AutomationServer();
|
||||
|
||||
void set_handler(RequestHandler handler) { m_handler = std::move(handler); }
|
||||
void set_health_text(std::string text) { m_health = std::move(text); }
|
||||
|
||||
void start(); // binds to 127.0.0.1:port, starts the io thread
|
||||
void stop(); // stops the io thread, joins
|
||||
bool is_started() const { return m_started; }
|
||||
unsigned short port() const { return m_port; }
|
||||
|
||||
private:
|
||||
void do_accept();
|
||||
void handle_session(boost::asio::ip::tcp::socket socket);
|
||||
|
||||
unsigned short m_port;
|
||||
std::atomic<bool> m_started{false};
|
||||
RequestHandler m_handler;
|
||||
std::string m_health{"OrcaSlicer automation server"};
|
||||
std::unique_ptr<boost::asio::io_context> m_ioc;
|
||||
std::unique_ptr<boost::asio::ip::tcp::acceptor> m_acceptor;
|
||||
boost::thread m_thread;
|
||||
};
|
||||
|
||||
}}} // namespace
|
||||
113
src/slic3r/GUI/Automation/IUiBackend.hpp
Normal file
113
src/slic3r/GUI/Automation/IUiBackend.hpp
Normal file
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
// PURE header: no wx / ImGui / GL includes. Safe to compile in the display-free
|
||||
// unit-test target. Shared by the dispatcher, serializer, locator, and backends.
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
enum class BackendKind { Wx, ImGui };
|
||||
|
||||
struct Rect { int x = 0, y = 0, w = 0, h = 0; };
|
||||
|
||||
// One node of the unified UI tree. `handle` is opaque (wxWindow* cast to uintptr_t
|
||||
// for wx, item index for ImGui); it is used by WxUiBackend to recover concrete
|
||||
// objects and is NEVER serialized.
|
||||
struct UiNode {
|
||||
BackendKind backend = BackendKind::Wx;
|
||||
std::string id; // automation id if set, else derived path id
|
||||
std::string path; // positional path, e.g. "MainFrame/Panel[2]/Button[0]"
|
||||
std::string klass; // wx class name or imgui item type
|
||||
std::string label;
|
||||
Rect rect; // screen coordinates
|
||||
bool enabled = true;
|
||||
bool visible = true;
|
||||
bool has_value = false;
|
||||
std::string value; // when applicable (text/choice/check/slider)
|
||||
std::uint64_t handle = 0;
|
||||
std::vector<UiNode> children; // wx only; imgui items are flat under their window
|
||||
};
|
||||
|
||||
struct DumpOptions {
|
||||
std::optional<std::string> root; // id/path to root the dump at
|
||||
int max_depth = -1; // -1 = unlimited
|
||||
bool visible_only = false;
|
||||
bool include_imgui = true;
|
||||
};
|
||||
|
||||
enum class MouseButton { Left, Right, Middle };
|
||||
enum class KeyModifier { Ctrl, Shift, Alt, Cmd };
|
||||
|
||||
struct KeyChord {
|
||||
std::vector<KeyModifier> modifiers;
|
||||
std::string key; // normalized lowercase: "s", "enter", "f5", "tab", ...
|
||||
};
|
||||
|
||||
struct AppState {
|
||||
std::string active_tab;
|
||||
bool project_loaded = false;
|
||||
bool slicing = false;
|
||||
int slice_progress = -1; // -1 = unknown
|
||||
std::optional<std::string> modal_dialog;
|
||||
bool foreground = false;
|
||||
};
|
||||
|
||||
struct PngImage {
|
||||
std::vector<unsigned char> png; // encoded PNG bytes
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
// Thrown by backends/dispatcher; carries a JSON-RPC application error code.
|
||||
struct AutomationError : std::runtime_error {
|
||||
int code;
|
||||
AutomationError(int code, std::string msg)
|
||||
: std::runtime_error(std::move(msg)), code(code) {}
|
||||
};
|
||||
|
||||
// Backend abstraction. The dispatcher orchestrates; the backend only snapshots
|
||||
// and executes primitives on already-resolved nodes.
|
||||
class IUiBackend {
|
||||
public:
|
||||
virtual ~IUiBackend() = default;
|
||||
|
||||
// Force a fresh frame so transient ImGui items are recorded before a read or
|
||||
// action. No-op for non-GUI backends.
|
||||
virtual void refresh_ui() = 0;
|
||||
|
||||
// Snapshot the UI tree (wx hierarchy + flat imgui items under their windows).
|
||||
virtual UiNode dump_tree(const DumpOptions& opts) = 0;
|
||||
|
||||
// Application-level state snapshot.
|
||||
virtual AppState app_state() = 0;
|
||||
|
||||
// Click a resolved node (uses its rect/handle). Raises/focuses first.
|
||||
virtual bool click(const UiNode& node, MouseButton button, bool dbl,
|
||||
const std::vector<KeyModifier>& modifiers) = 0;
|
||||
// Type into the currently-focused control.
|
||||
virtual bool type_text(const std::string& text) = 0;
|
||||
// Send key chords (e.g. ctrl+s) to the focused window.
|
||||
virtual bool send_keys(const std::vector<KeyChord>& chords) = 0;
|
||||
|
||||
// Screenshot. target == nullptr => main frame. Captured from the on-screen
|
||||
// composited framebuffer, so it includes the GL viewport and ImGui overlays.
|
||||
virtual PngImage screenshot_window(const UiNode* target) = 0;
|
||||
|
||||
// Load one or more files (absolute paths) into the running instance on the GUI
|
||||
// thread. Returns the number of objects added to the scene (load_files(...).size()).
|
||||
// Throws AutomationError(kErrLoadFailed) when nothing loads. Header stays wx-free:
|
||||
// the concrete LoadStrategy is chosen inside WxUiBackend, not exposed here.
|
||||
virtual int open_files(const std::vector<std::string>& paths) = 0;
|
||||
|
||||
// Select a top-level view/tab by stable name (e.g. "prepare", "preview", "home",
|
||||
// "device", "project", "calibration", "multi_device") on the GUI thread. Returns
|
||||
// the resulting tab index. Throws AutomationError(kErrNotFound) when the named
|
||||
// view is unknown or not available in the current layout. The wx-specific
|
||||
// name->tab mapping lives in WxUiBackend/MainFrame, not here.
|
||||
virtual int select_view(const std::string& view) = 0;
|
||||
};
|
||||
|
||||
}}} // namespace Slic3r::GUI::Automation
|
||||
31
src/slic3r/GUI/Automation/ImGuiItemTable.cpp
Normal file
31
src/slic3r/GUI/Automation/ImGuiItemTable.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include "ImGuiItemTable.hpp"
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
ImGuiItemTable& ImGuiItemTable::instance() {
|
||||
static ImGuiItemTable t;
|
||||
return t;
|
||||
}
|
||||
|
||||
void ImGuiItemTable::record_item(ImGuiItemRecord rec) {
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
m_back.items.push_back(std::move(rec));
|
||||
}
|
||||
|
||||
void ImGuiItemTable::record_window(ImGuiWindowRecord rec) {
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
m_back.windows.push_back(std::move(rec));
|
||||
}
|
||||
|
||||
void ImGuiItemTable::swap_frame() {
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
m_front = std::move(m_back);
|
||||
m_back = ImGuiFrameRecord{};
|
||||
}
|
||||
|
||||
ImGuiFrameRecord ImGuiItemTable::snapshot() const {
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
return m_front;
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
56
src/slic3r/GUI/Automation/ImGuiItemTable.hpp
Normal file
56
src/slic3r/GUI/Automation/ImGuiItemTable.hpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// One recorded ImGui item. Rect is in ImGui display coords; WxUiBackend maps it
|
||||
// to screen coords using the canvas client origin + DPI scale.
|
||||
struct ImGuiItemRecord {
|
||||
std::string window_name;
|
||||
std::string label; // visible label / id
|
||||
std::string type; // "button", "checkbox", "combo", "slider", "input", ...
|
||||
float x = 0, y = 0, w = 0, h = 0;
|
||||
bool enabled = true;
|
||||
bool has_value = false;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
// A complete recorded frame: items + window-level info.
|
||||
struct ImGuiWindowRecord {
|
||||
std::string name;
|
||||
float x = 0, y = 0, w = 0, h = 0;
|
||||
bool visible = true;
|
||||
};
|
||||
|
||||
struct ImGuiFrameRecord {
|
||||
std::vector<ImGuiItemRecord> items;
|
||||
std::vector<ImGuiWindowRecord> windows;
|
||||
};
|
||||
|
||||
// Double-buffered recorder. The drawing code appends to the "back" frame; render()
|
||||
// swaps it to "front" at frame end. Readers (GUI thread, after marshaling) read the
|
||||
// front frame. All access is on the GUI thread, but we guard with a mutex anyway
|
||||
// because the automation read may happen between frames.
|
||||
class ImGuiItemTable {
|
||||
public:
|
||||
static ImGuiItemTable& instance();
|
||||
|
||||
// Called from ImGuiWrapper drawing hooks (GUI thread). No-op cheap append.
|
||||
void record_item(ImGuiItemRecord rec);
|
||||
void record_window(ImGuiWindowRecord rec);
|
||||
|
||||
// Called at frame end (ImGuiWrapper::render). Promotes back -> front, clears back.
|
||||
void swap_frame();
|
||||
|
||||
// Snapshot the latest complete frame for the backend to read.
|
||||
ImGuiFrameRecord snapshot() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex m_mutex;
|
||||
ImGuiFrameRecord m_back; // accumulating
|
||||
ImGuiFrameRecord m_front; // last complete
|
||||
};
|
||||
|
||||
}}} // namespace
|
||||
402
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp
Normal file
402
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp
Normal file
@@ -0,0 +1,402 @@
|
||||
#include "JsonRpcDispatcher.hpp"
|
||||
#include "WidgetSerializer.hpp"
|
||||
#include "Locator.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
JsonRpcDispatcher::JsonRpcDispatcher(IUiBackend& backend) : m_backend(backend) {}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::make_result(const nlohmann::json& id, nlohmann::json result) {
|
||||
return { {"jsonrpc","2.0"}, {"id", id}, {"result", std::move(result)} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::make_error(const nlohmann::json& id, int code,
|
||||
const std::string& msg) {
|
||||
return { {"jsonrpc","2.0"}, {"id", id},
|
||||
{"error", { {"code", code}, {"message", msg} }} };
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::optional<std::string> opt_str(const nlohmann::json& p, const char* key) {
|
||||
if (p.is_object() && p.contains(key) && p.at(key).is_string())
|
||||
return p.at(key).get<std::string>();
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Target parse_target(const nlohmann::json& tj) {
|
||||
Target t;
|
||||
if (!tj.is_object()) return t;
|
||||
t.id = opt_str(tj, "id");
|
||||
t.path = opt_str(tj, "path");
|
||||
t.name = opt_str(tj, "name");
|
||||
t.klass = opt_str(tj, "class");
|
||||
t.label = opt_str(tj, "label");
|
||||
t.value = opt_str(tj, "value");
|
||||
if (auto b = opt_str(tj, "backend"))
|
||||
t.backend = (*b == "imgui") ? BackendKind::ImGui : BackendKind::Wx;
|
||||
return t;
|
||||
}
|
||||
|
||||
DumpOptions parse_dump_options(const nlohmann::json& p) {
|
||||
DumpOptions o;
|
||||
if (p.is_object()) {
|
||||
if (p.contains("root")) o.root = opt_str(p, "root");
|
||||
if (p.contains("max_depth") && p.at("max_depth").is_number_integer())
|
||||
o.max_depth = p.at("max_depth").get<int>();
|
||||
if (p.contains("visible_only") && p.at("visible_only").is_boolean())
|
||||
o.visible_only = p.at("visible_only").get<bool>();
|
||||
if (p.contains("include_imgui") && p.at("include_imgui").is_boolean())
|
||||
o.include_imgui = p.at("include_imgui").get<bool>();
|
||||
}
|
||||
return o;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
MouseButton parse_button(const nlohmann::json& p) {
|
||||
auto b = opt_str(p, "button");
|
||||
if (b && *b == "right") return MouseButton::Right;
|
||||
if (b && *b == "middle") return MouseButton::Middle;
|
||||
return MouseButton::Left;
|
||||
}
|
||||
|
||||
std::vector<KeyModifier> parse_modifiers(const nlohmann::json& p) {
|
||||
std::vector<KeyModifier> mods;
|
||||
if (p.is_object() && p.contains("modifiers") && p.at("modifiers").is_array()) {
|
||||
for (const auto& m : p.at("modifiers")) {
|
||||
if (!m.is_string()) continue;
|
||||
const std::string s = m.get<std::string>();
|
||||
if (s == "ctrl") mods.push_back(KeyModifier::Ctrl);
|
||||
else if (s == "shift") mods.push_back(KeyModifier::Shift);
|
||||
else if (s == "alt") mods.push_back(KeyModifier::Alt);
|
||||
else if (s == "cmd" || s == "meta") mods.push_back(KeyModifier::Cmd);
|
||||
}
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
// Parse one chord token list (already split): the last token is the key, the
|
||||
// earlier ones are modifiers.
|
||||
KeyChord chord_from_tokens(const std::vector<std::string>& tokens) {
|
||||
KeyChord c;
|
||||
for (size_t i = 0; i < tokens.size(); ++i) {
|
||||
const std::string& t = tokens[i];
|
||||
const bool is_mod = (t == "ctrl" || t == "shift" || t == "alt" ||
|
||||
t == "cmd" || t == "meta");
|
||||
if (is_mod && i + 1 < tokens.size()) {
|
||||
if (t == "ctrl") c.modifiers.push_back(KeyModifier::Ctrl);
|
||||
else if (t == "shift") c.modifiers.push_back(KeyModifier::Shift);
|
||||
else if (t == "alt") c.modifiers.push_back(KeyModifier::Alt);
|
||||
else c.modifiers.push_back(KeyModifier::Cmd);
|
||||
} else {
|
||||
c.key = t; // last token (or a lone token) is the key
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
std::vector<std::string> split(const std::string& s, char delim) {
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
for (char ch : s) {
|
||||
if (ch == delim) { if (!cur.empty()) out.push_back(cur); cur.clear(); }
|
||||
else cur.push_back(ch);
|
||||
}
|
||||
if (!cur.empty()) out.push_back(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
// "keys" may be a string ("ctrl+s") or an array (["ctrl","s"]). Returns one chord.
|
||||
std::vector<KeyChord> parse_keys(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("keys"))
|
||||
throw AutomationError(kInvalidParams, "input.key requires 'keys'");
|
||||
const auto& k = params.at("keys");
|
||||
std::vector<std::string> tokens;
|
||||
if (k.is_string()) {
|
||||
tokens = split(k.get<std::string>(), '+');
|
||||
} else if (k.is_array()) {
|
||||
for (const auto& e : k)
|
||||
if (e.is_string()) tokens.push_back(e.get<std::string>());
|
||||
} else {
|
||||
throw AutomationError(kInvalidParams, "'keys' must be string or array");
|
||||
}
|
||||
if (tokens.empty())
|
||||
throw AutomationError(kInvalidParams, "'keys' is empty");
|
||||
return { chord_from_tokens(tokens) };
|
||||
}
|
||||
|
||||
// "paths" may be a single string ("C:/a.stl") or an array of strings. Returns the
|
||||
// non-empty absolute paths; throws kInvalidParams when paths is missing, not a
|
||||
// string/array, contains a non-string entry, or yields no non-empty path.
|
||||
std::vector<std::string> parse_paths(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("paths"))
|
||||
throw AutomationError(kInvalidParams, "file.open requires 'paths'");
|
||||
const auto& p = params.at("paths");
|
||||
std::vector<std::string> out;
|
||||
if (p.is_string()) {
|
||||
out.push_back(p.get<std::string>());
|
||||
} else if (p.is_array()) {
|
||||
for (const auto& e : p) {
|
||||
if (!e.is_string())
|
||||
throw AutomationError(kInvalidParams, "'paths' entries must be strings");
|
||||
out.push_back(e.get<std::string>());
|
||||
}
|
||||
} else {
|
||||
throw AutomationError(kInvalidParams, "'paths' must be a string or array");
|
||||
}
|
||||
out.erase(std::remove_if(out.begin(), out.end(),
|
||||
[](const std::string& s) { return s.empty(); }),
|
||||
out.end());
|
||||
if (out.empty())
|
||||
throw AutomationError(kInvalidParams, "'paths' is empty");
|
||||
return out;
|
||||
}
|
||||
|
||||
// "view" must be a non-empty string naming a top-level tab. The name->tab mapping
|
||||
// itself lives in the wx backend, so this only validates shape; throws kInvalidParams
|
||||
// when view is missing, not a string, or empty.
|
||||
std::string parse_view(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("view"))
|
||||
throw AutomationError(kInvalidParams, "view.select requires 'view'");
|
||||
const auto& v = params.at("view");
|
||||
if (!v.is_string())
|
||||
throw AutomationError(kInvalidParams, "'view' must be a string");
|
||||
std::string name = v.get<std::string>();
|
||||
if (name.empty())
|
||||
throw AutomationError(kInvalidParams, "'view' is empty");
|
||||
return name;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
std::string base64_encode(const std::vector<unsigned char>& data) {
|
||||
static const char* tbl =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve(((data.size() + 2) / 3) * 4);
|
||||
size_t i = 0;
|
||||
for (; i + 2 < data.size(); i += 3) {
|
||||
const unsigned n = (data[i] << 16) | (data[i+1] << 8) | data[i+2];
|
||||
out.push_back(tbl[(n >> 18) & 63]);
|
||||
out.push_back(tbl[(n >> 12) & 63]);
|
||||
out.push_back(tbl[(n >> 6) & 63]);
|
||||
out.push_back(tbl[n & 63]);
|
||||
}
|
||||
if (i < data.size()) {
|
||||
unsigned n = data[i] << 16;
|
||||
const bool two = (i + 1 < data.size());
|
||||
if (two) n |= data[i+1] << 8;
|
||||
out.push_back(tbl[(n >> 18) & 63]);
|
||||
out.push_back(tbl[(n >> 12) & 63]);
|
||||
out.push_back(two ? tbl[(n >> 6) & 63] : '=');
|
||||
out.push_back('=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json image_to_json(const PngImage& img) {
|
||||
if (img.png.empty())
|
||||
throw AutomationError(kErrScreenshotFail, "screenshot produced no data");
|
||||
return { {"png_base64", base64_encode(img.png)},
|
||||
{"width", img.width}, {"height", img.height} };
|
||||
}
|
||||
} // namespace
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) {
|
||||
return { {"version", kAutomationVersion},
|
||||
{"protocol", "2.0"},
|
||||
{"capabilities", nlohmann::json::array({
|
||||
"tree.dump","tree.find","widget.get","input.click","input.type",
|
||||
"input.key","sync.wait_for","app.state","screenshot.window",
|
||||
"file.open","view.select" })} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::dispatch(const nlohmann::json& request) {
|
||||
nlohmann::json id = request.contains("id") ? request.at("id") : nlohmann::json(nullptr);
|
||||
|
||||
if (!request.is_object() || !request.contains("method") ||
|
||||
!request.at("method").is_string()) {
|
||||
return make_error(id, kInvalidRequest, "missing or invalid 'method'");
|
||||
}
|
||||
const std::string method = request.at("method").get<std::string>();
|
||||
const nlohmann::json params =
|
||||
request.contains("params") ? request.at("params") : nlohmann::json::object();
|
||||
|
||||
try {
|
||||
if (method == "automation.version") return make_result(id, m_version(params));
|
||||
if (method == "tree.dump") return make_result(id, m_tree_dump(params));
|
||||
if (method == "tree.find") return make_result(id, m_tree_find(params));
|
||||
if (method == "widget.get") return make_result(id, m_widget_get(params));
|
||||
if (method == "input.click") return make_result(id, m_input_click(params));
|
||||
if (method == "input.type") return make_result(id, m_input_type(params));
|
||||
if (method == "input.key") return make_result(id, m_input_key(params));
|
||||
if (method == "sync.wait_for") return make_result(id, m_sync_wait_for(params));
|
||||
if (method == "app.state") return make_result(id, m_app_state(params));
|
||||
if (method == "screenshot.window") return make_result(id, m_screenshot_window(params));
|
||||
if (method == "file.open") return make_result(id, m_file_open(params));
|
||||
if (method == "view.select") return make_result(id, m_view_select(params));
|
||||
return make_error(id, kMethodNotFound, "unknown method: " + method);
|
||||
} catch (const AutomationError& e) {
|
||||
return make_error(id, e.code, e.what());
|
||||
} catch (const std::exception& e) {
|
||||
return make_error(id, kInvalidParams, e.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::string JsonRpcDispatcher::handle_request(const std::string& body) {
|
||||
nlohmann::json req;
|
||||
try {
|
||||
req = nlohmann::json::parse(body);
|
||||
} catch (const std::exception& e) {
|
||||
return make_error(nullptr, kParseError, std::string("parse error: ") + e.what()).dump();
|
||||
}
|
||||
return dispatch(req).dump();
|
||||
}
|
||||
|
||||
// --- method handlers implemented in Tasks 7-10 (remaining stubs throw for now) ---
|
||||
nlohmann::json JsonRpcDispatcher::m_tree_dump(const nlohmann::json& params) {
|
||||
m_backend.refresh_ui();
|
||||
const UiNode root = m_backend.dump_tree(parse_dump_options(params));
|
||||
return node_to_json(root, /*include_children*/ true);
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_tree_find(const nlohmann::json& params) {
|
||||
m_backend.refresh_ui();
|
||||
const UiNode root = m_backend.dump_tree(DumpOptions{});
|
||||
const Target target =
|
||||
parse_target(params.is_object() ? params : nlohmann::json::object());
|
||||
nlohmann::json arr = nlohmann::json::array();
|
||||
for (const UiNode* n : find_matches(root, target))
|
||||
arr.push_back(node_to_json(*n, /*include_children*/ false));
|
||||
return arr;
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_widget_get(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("target"))
|
||||
throw AutomationError(kInvalidParams, "widget.get requires 'target'");
|
||||
m_backend.refresh_ui();
|
||||
const UiNode root = m_backend.dump_tree(DumpOptions{});
|
||||
int count = 0;
|
||||
const UiNode* node = resolve_unique(root, parse_target(params.at("target")), count);
|
||||
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
|
||||
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
|
||||
return node_to_json(*node, /*include_children*/ true);
|
||||
}
|
||||
|
||||
const UiNode JsonRpcDispatcher::resolve_actionable(const nlohmann::json& params,
|
||||
UiNode& tree_out) {
|
||||
if (!params.is_object() || !params.contains("target"))
|
||||
throw AutomationError(kInvalidParams, "missing 'target'");
|
||||
m_backend.refresh_ui();
|
||||
tree_out = m_backend.dump_tree(DumpOptions{});
|
||||
int count = 0;
|
||||
const UiNode* node = resolve_unique(tree_out, parse_target(params.at("target")), count);
|
||||
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
|
||||
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
|
||||
if (!node->enabled || !node->visible)
|
||||
throw AutomationError(kErrNotActionable, "target is disabled or hidden");
|
||||
return *node; // copy: stable even though tree_out outlives this call
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json& params) {
|
||||
UiNode tree;
|
||||
const UiNode node = resolve_actionable(params, tree);
|
||||
const bool dbl = params.contains("double") && params.at("double").is_boolean()
|
||||
&& params.at("double").get<bool>();
|
||||
const bool ok = m_backend.click(node, parse_button(params), dbl, parse_modifiers(params));
|
||||
return { {"ok", ok} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("text") || !params.at("text").is_string())
|
||||
throw AutomationError(kInvalidParams, "input.type requires string 'text'");
|
||||
const std::string text = params.at("text").get<std::string>();
|
||||
// Optional target: click to focus first.
|
||||
if (params.contains("target")) {
|
||||
UiNode tree;
|
||||
const UiNode node = resolve_actionable(params, tree);
|
||||
m_backend.click(node, MouseButton::Left, false, {});
|
||||
}
|
||||
const bool ok = m_backend.type_text(text);
|
||||
return { {"ok", ok} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json& params) {
|
||||
const bool ok = m_backend.send_keys(parse_keys(params));
|
||||
return { {"ok", ok} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_sync_wait_for(const nlohmann::json& params) {
|
||||
if (!params.is_object() || !params.contains("target") || !params.contains("state"))
|
||||
throw AutomationError(kInvalidParams, "sync.wait_for requires 'target' and 'state'");
|
||||
|
||||
const Target target = parse_target(params.at("target"));
|
||||
const std::string state_s = params.at("state").get<std::string>();
|
||||
WaitState state;
|
||||
if (state_s == "exists") state = WaitState::Exists;
|
||||
else if (state_s == "visible") state = WaitState::Visible;
|
||||
else if (state_s == "enabled") state = WaitState::Enabled;
|
||||
else if (state_s == "value") state = WaitState::Value;
|
||||
else throw AutomationError(kInvalidParams, "unknown state: " + state_s);
|
||||
|
||||
std::optional<std::string> expected = opt_str(params, "value");
|
||||
const int timeout_ms = params.contains("timeout_ms") && params.at("timeout_ms").is_number_integer()
|
||||
? params.at("timeout_ms").get<int>() : 5000;
|
||||
const int poll_ms = params.contains("poll_ms") && params.at("poll_ms").is_number_integer()
|
||||
? std::max(1, params.at("poll_ms").get<int>()) : 100;
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
for (;;) {
|
||||
m_backend.refresh_ui();
|
||||
const UiNode root = m_backend.dump_tree(DumpOptions{});
|
||||
int count = 0;
|
||||
const UiNode* node = resolve_unique(root, target, count);
|
||||
if (evaluate_state(node, state, expected)) {
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start).count();
|
||||
return { {"ok", true}, {"elapsed_ms", static_cast<int>(elapsed)} };
|
||||
}
|
||||
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start).count();
|
||||
if (elapsed_ms >= timeout_ms)
|
||||
throw AutomationError(kErrWaitTimeout, "wait_for timed out for state: " + state_s);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(poll_ms));
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_app_state(const nlohmann::json&) {
|
||||
return app_state_to_json(m_backend.app_state());
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json& params) {
|
||||
m_backend.refresh_ui();
|
||||
const UiNode* target_ptr = nullptr;
|
||||
UiNode resolved;
|
||||
if (params.is_object() && params.contains("target")) {
|
||||
UiNode tree = m_backend.dump_tree(DumpOptions{});
|
||||
int count = 0;
|
||||
const UiNode* n = resolve_unique(tree, parse_target(params.at("target")), count);
|
||||
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
|
||||
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
|
||||
resolved = *n;
|
||||
target_ptr = &resolved;
|
||||
}
|
||||
return image_to_json(m_backend.screenshot_window(target_ptr));
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_file_open(const nlohmann::json& params) {
|
||||
const std::vector<std::string> paths = parse_paths(params);
|
||||
const int loaded = m_backend.open_files(paths);
|
||||
return { {"ok", true}, {"loaded", loaded} };
|
||||
}
|
||||
|
||||
nlohmann::json JsonRpcDispatcher::m_view_select(const nlohmann::json& params) {
|
||||
const std::string view = parse_view(params);
|
||||
const int index = m_backend.select_view(view);
|
||||
return { {"ok", true}, {"view", view}, {"index", index} };
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
62
src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp
Normal file
62
src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp
Normal file
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
#include "IUiBackend.hpp"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// JSON-RPC 2.0 standard error codes.
|
||||
constexpr int kParseError = -32700;
|
||||
constexpr int kInvalidRequest = -32600;
|
||||
constexpr int kMethodNotFound = -32601;
|
||||
constexpr int kInvalidParams = -32602;
|
||||
// Application error codes (design spec §5).
|
||||
constexpr int kErrNotFound = 1001; // widget/target not found (or ambiguous)
|
||||
constexpr int kErrNotActionable = 1002; // disabled / hidden
|
||||
constexpr int kErrWaitTimeout = 1003;
|
||||
constexpr int kErrGuiBusy = 1004; // GUI thread timeout
|
||||
constexpr int kErrScreenshotFail = 1005;
|
||||
constexpr int kErrDisabled = 1006;
|
||||
constexpr int kErrLoadFailed = 1007; // file.open: load_files returned empty / threw
|
||||
|
||||
constexpr const char* kProtocolVersion = "2.0";
|
||||
constexpr const char* kAutomationVersion = "1.0.0";
|
||||
|
||||
class JsonRpcDispatcher {
|
||||
public:
|
||||
explicit JsonRpcDispatcher(IUiBackend& backend);
|
||||
|
||||
// Parse a JSON-RPC request body, dispatch, and return the response body.
|
||||
// Never throws; transport-level/parse errors become JSON-RPC error responses.
|
||||
std::string handle_request(const std::string& body);
|
||||
|
||||
// For tests: dispatch an already-parsed request object and return the response.
|
||||
nlohmann::json dispatch(const nlohmann::json& request);
|
||||
|
||||
private:
|
||||
nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result);
|
||||
nlohmann::json make_error(const nlohmann::json& id, int code, const std::string& msg);
|
||||
|
||||
// Method handlers (each returns the `result` object or throws AutomationError).
|
||||
nlohmann::json m_version(const nlohmann::json& params);
|
||||
nlohmann::json m_tree_dump(const nlohmann::json& params);
|
||||
nlohmann::json m_tree_find(const nlohmann::json& params);
|
||||
nlohmann::json m_widget_get(const nlohmann::json& params);
|
||||
nlohmann::json m_input_click(const nlohmann::json& params);
|
||||
nlohmann::json m_input_type(const nlohmann::json& params);
|
||||
nlohmann::json m_input_key(const nlohmann::json& params);
|
||||
nlohmann::json m_sync_wait_for(const nlohmann::json& params);
|
||||
nlohmann::json m_app_state(const nlohmann::json& params);
|
||||
nlohmann::json m_screenshot_window(const nlohmann::json& params);
|
||||
nlohmann::json m_file_open(const nlohmann::json& params);
|
||||
nlohmann::json m_view_select(const nlohmann::json& params);
|
||||
|
||||
// Resolve a unique, actionable (enabled+visible) node from params["target"].
|
||||
// Throws kErrNotFound (missing/ambiguous) or kErrNotActionable (disabled/hidden).
|
||||
// `tree_out` keeps the snapshot alive; the returned node is a stable copy.
|
||||
const UiNode resolve_actionable(const nlohmann::json& params, UiNode& tree_out);
|
||||
|
||||
IUiBackend& m_backend;
|
||||
};
|
||||
|
||||
}}} // namespace
|
||||
68
src/slic3r/GUI/Automation/Locator.cpp
Normal file
68
src/slic3r/GUI/Automation/Locator.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
#include "Locator.hpp"
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
static void flatten_into(const UiNode& n, std::vector<const UiNode*>& out) {
|
||||
out.push_back(&n);
|
||||
for (const UiNode& c : n.children)
|
||||
flatten_into(c, out);
|
||||
}
|
||||
|
||||
std::vector<const UiNode*> flatten(const UiNode& root) {
|
||||
std::vector<const UiNode*> out;
|
||||
flatten_into(root, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool matches_predicate(const UiNode& n, const Target& t) {
|
||||
if (t.backend && n.backend != *t.backend) return false;
|
||||
if (t.name && !(n.id == *t.name || n.label == *t.name)) return false;
|
||||
if (t.klass && n.klass != *t.klass) return false;
|
||||
if (t.label && n.label != *t.label) return false;
|
||||
if (t.value && !(n.has_value && n.value == *t.value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<const UiNode*> find_matches(const UiNode& root, const Target& target) {
|
||||
const auto all = flatten(root);
|
||||
std::vector<const UiNode*> out;
|
||||
|
||||
// Resolution order: exact id -> exact path -> predicate.
|
||||
if (target.id) {
|
||||
for (const UiNode* n : all)
|
||||
if (n->id == *target.id) out.push_back(n);
|
||||
return out;
|
||||
}
|
||||
if (target.path) {
|
||||
for (const UiNode* n : all)
|
||||
if (n->path == *target.path) out.push_back(n);
|
||||
return out;
|
||||
}
|
||||
if (target.empty())
|
||||
return out; // nothing to match on
|
||||
for (const UiNode* n : all)
|
||||
if (matches_predicate(*n, target)) out.push_back(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
const UiNode* resolve_unique(const UiNode& root, const Target& target, int& match_count) {
|
||||
const auto m = find_matches(root, target);
|
||||
match_count = static_cast<int>(m.size());
|
||||
return m.size() == 1 ? m.front() : nullptr;
|
||||
}
|
||||
|
||||
bool evaluate_state(const UiNode* node, WaitState state,
|
||||
const std::optional<std::string>& expected_value) {
|
||||
if (node == nullptr)
|
||||
return false;
|
||||
switch (state) {
|
||||
case WaitState::Exists: return true;
|
||||
case WaitState::Visible: return node->visible;
|
||||
case WaitState::Enabled: return node->enabled && node->visible;
|
||||
case WaitState::Value:
|
||||
return node->has_value && expected_value && node->value == *expected_value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
41
src/slic3r/GUI/Automation/Locator.hpp
Normal file
41
src/slic3r/GUI/Automation/Locator.hpp
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
#include "IUiBackend.hpp"
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// A target specification. Resolution order: id -> path -> predicate
|
||||
// (name OR class OR label OR value, all provided fields must match).
|
||||
struct Target {
|
||||
std::optional<std::string> id;
|
||||
std::optional<std::string> path;
|
||||
std::optional<std::string> name; // matches id OR label
|
||||
std::optional<std::string> klass;
|
||||
std::optional<std::string> label;
|
||||
std::optional<std::string> value;
|
||||
std::optional<BackendKind> backend;
|
||||
bool empty() const {
|
||||
return !id && !path && !name && !klass && !label && !value;
|
||||
}
|
||||
};
|
||||
|
||||
// Depth-first flatten of a tree into stable-ordered pointers (parents before children).
|
||||
std::vector<const UiNode*> flatten(const UiNode& root);
|
||||
|
||||
// All nodes matching the target spec (resolution-order aware).
|
||||
std::vector<const UiNode*> find_matches(const UiNode& root, const Target& target);
|
||||
|
||||
// Resolve to exactly one node for actions. Returns the node on a unique match;
|
||||
// returns nullptr otherwise and sets match_count (0 = not found, >1 = ambiguous).
|
||||
const UiNode* resolve_unique(const UiNode& root, const Target& target, int& match_count);
|
||||
|
||||
enum class WaitState { Exists, Visible, Enabled, Value };
|
||||
|
||||
// True if `node` satisfies the wait condition. A null node only satisfies a
|
||||
// negative... here we keep it simple: null => false for all states.
|
||||
bool evaluate_state(const UiNode* node, WaitState state,
|
||||
const std::optional<std::string>& expected_value);
|
||||
|
||||
}}} // namespace
|
||||
43
src/slic3r/GUI/Automation/WidgetSerializer.cpp
Normal file
43
src/slic3r/GUI/Automation/WidgetSerializer.cpp
Normal file
@@ -0,0 +1,43 @@
|
||||
#include "WidgetSerializer.hpp"
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
static const char* backend_name(BackendKind k) {
|
||||
return k == BackendKind::Wx ? "wx" : "imgui";
|
||||
}
|
||||
|
||||
nlohmann::json node_to_json(const UiNode& node, bool include_children) {
|
||||
nlohmann::json j;
|
||||
j["backend"] = backend_name(node.backend);
|
||||
j["id"] = node.id;
|
||||
j["path"] = node.path;
|
||||
j["class"] = node.klass;
|
||||
j["label"] = node.label;
|
||||
j["rect"] = { {"x", node.rect.x}, {"y", node.rect.y},
|
||||
{"w", node.rect.w}, {"h", node.rect.h} };
|
||||
j["enabled"] = node.enabled;
|
||||
j["visible"] = node.visible;
|
||||
if (node.has_value)
|
||||
j["value"] = node.value;
|
||||
if (include_children && node.backend == BackendKind::Wx) {
|
||||
nlohmann::json arr = nlohmann::json::array();
|
||||
for (const UiNode& c : node.children)
|
||||
arr.push_back(node_to_json(c, true));
|
||||
j["children"] = std::move(arr);
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
nlohmann::json app_state_to_json(const AppState& s) {
|
||||
nlohmann::json j;
|
||||
j["active_tab"] = s.active_tab;
|
||||
j["project_loaded"] = s.project_loaded;
|
||||
j["slicing"] = s.slicing;
|
||||
j["slice_progress"] = s.slice_progress;
|
||||
j["foreground"] = s.foreground;
|
||||
if (s.modal_dialog)
|
||||
j["modal_dialog"] = *s.modal_dialog;
|
||||
return j;
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
14
src/slic3r/GUI/Automation/WidgetSerializer.hpp
Normal file
14
src/slic3r/GUI/Automation/WidgetSerializer.hpp
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include "IUiBackend.hpp"
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// Serialize a node to the unified JSON shape from the design spec (§5).
|
||||
// `include_children` controls recursion into UiNode::children.
|
||||
nlohmann::json node_to_json(const UiNode& node, bool include_children);
|
||||
|
||||
// Serialize an application-state snapshot.
|
||||
nlohmann::json app_state_to_json(const AppState& state);
|
||||
|
||||
}}} // namespace
|
||||
341
src/slic3r/GUI/Automation/WxUiBackend.cpp
Normal file
341
src/slic3r/GUI/Automation/WxUiBackend.cpp
Normal file
@@ -0,0 +1,341 @@
|
||||
#include "WxUiBackend.hpp"
|
||||
#include "AutomationRegistry.hpp"
|
||||
#include "ImGuiItemTable.hpp"
|
||||
#include "JsonRpcDispatcher.hpp" // for kErrGuiBusy and friends
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/MainFrame.hpp"
|
||||
#include "slic3r/GUI/Plater.hpp"
|
||||
#include "slic3r/GUI/GLCanvas3D.hpp" // get_current_canvas3D() for app.state
|
||||
#include "libslic3r/Model.hpp"
|
||||
|
||||
#include <wx/window.h>
|
||||
#include <wx/toplevel.h>
|
||||
#include <wx/dialog.h> // wxDialog::IsModal
|
||||
#include <wx/glcanvas.h> // wxGLCanvas -> wxWindow* conversion
|
||||
#include <wx/textctrl.h> // wxTextEntry
|
||||
#include <wx/choice.h>
|
||||
#include <wx/checkbox.h>
|
||||
#include <wx/uiaction.h> // wxUIActionSimulator (synthetic mouse/keyboard)
|
||||
#include <wx/dcscreen.h> // wxScreenDC
|
||||
#include <wx/dcmemory.h> // wxMemoryDC
|
||||
#include <wx/mstream.h> // wxMemoryOutputStream
|
||||
|
||||
#include <cctype> // std::toupper
|
||||
#include <cstdlib> // std::atoi
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <type_traits>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// Run `fn` on the GUI thread, block until it returns or the timeout elapses.
|
||||
// Throws AutomationError(kErrGuiBusy) on timeout. std::promise is move-only, so we
|
||||
// hold it via shared_ptr to satisfy CallAfter's copyable-functor requirement.
|
||||
template <class Fn>
|
||||
static auto run_on_gui(int timeout_ms, Fn&& fn) -> decltype(fn()) {
|
||||
using R = decltype(fn());
|
||||
auto prom = std::make_shared<std::promise<R>>();
|
||||
auto fut = prom->get_future();
|
||||
wxGetApp().CallAfter([prom, fn = std::forward<Fn>(fn)]() mutable {
|
||||
try {
|
||||
if constexpr (std::is_void_v<R>) { fn(); prom->set_value(); }
|
||||
else { prom->set_value(fn()); }
|
||||
} catch (...) { prom->set_exception(std::current_exception()); }
|
||||
});
|
||||
if (fut.wait_for(std::chrono::milliseconds(timeout_ms)) != std::future_status::ready)
|
||||
throw AutomationError(kErrGuiBusy, "GUI thread timed out");
|
||||
return fut.get();
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string wx_class_name(const wxWindow* w) {
|
||||
const wxClassInfo* ci = w->GetClassInfo();
|
||||
std::string name = ci ? std::string(wxString(ci->GetClassName()).ToUTF8()) : "wxWindow";
|
||||
if (name.rfind("wx", 0) == 0 && name.size() > 2) name = name.substr(2);
|
||||
return name;
|
||||
}
|
||||
|
||||
std::string wx_value_of(wxWindow* w, bool& has_value) {
|
||||
has_value = false;
|
||||
if (auto* tc = dynamic_cast<wxTextEntry*>(w)) { has_value = true; return std::string(tc->GetValue().ToUTF8()); }
|
||||
if (auto* ch = dynamic_cast<wxChoice*>(w)) { has_value = true; return std::string(ch->GetStringSelection().ToUTF8()); }
|
||||
if (auto* cb = dynamic_cast<wxCheckBox*>(w)) { has_value = true; return cb->GetValue() ? "true" : "false"; }
|
||||
return {};
|
||||
}
|
||||
|
||||
void build_node(wxWindow* w, UiNode& node, const std::string& parent_path,
|
||||
int sibling_index, const DumpOptions& opts, int depth) {
|
||||
node.backend = BackendKind::Wx;
|
||||
node.klass = wx_class_name(w);
|
||||
node.id = automation_id_of(w);
|
||||
node.path = parent_path.empty()
|
||||
? node.klass
|
||||
: parent_path + "/" + node.klass + "[" + std::to_string(sibling_index) + "]";
|
||||
node.label = std::string(w->GetLabel().ToUTF8());
|
||||
node.enabled = w->IsEnabled();
|
||||
node.visible = w->IsShownOnScreen();
|
||||
node.value = wx_value_of(w, node.has_value);
|
||||
node.handle = reinterpret_cast<std::uint64_t>(w);
|
||||
const wxRect r = w->GetScreenRect();
|
||||
node.rect = { r.x, r.y, r.width, r.height };
|
||||
|
||||
if (opts.max_depth >= 0 && depth >= opts.max_depth) return;
|
||||
int idx = 0;
|
||||
for (wxWindow* child : w->GetChildren()) {
|
||||
if (opts.visible_only && !child->IsShownOnScreen()) { ++idx; continue; }
|
||||
UiNode cn;
|
||||
build_node(child, cn, node.path, idx, opts, depth + 1);
|
||||
node.children.push_back(std::move(cn));
|
||||
++idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Map recorded ImGui items (display coords) to screen coords using the 3D canvas
|
||||
// client origin + DPI scale, then append them as flat children under the root.
|
||||
void append_imgui_nodes(UiNode& root) {
|
||||
Plater* plater = wxGetApp().plater();
|
||||
if (plater == nullptr) return;
|
||||
GLCanvas3D* canvas3d = plater->get_current_canvas3D();
|
||||
if (canvas3d == nullptr) return;
|
||||
wxWindow* canvas = canvas3d->get_wxglcanvas();
|
||||
if (canvas == nullptr) return;
|
||||
const wxPoint origin = canvas->ClientToScreen(wxPoint(0, 0));
|
||||
double scale = canvas->GetContentScaleFactor();
|
||||
if (scale <= 0.0) scale = 1.0;
|
||||
const auto frame = ImGuiItemTable::instance().snapshot();
|
||||
for (const auto& it : frame.items) {
|
||||
UiNode n;
|
||||
n.backend = BackendKind::ImGui;
|
||||
n.klass = it.type;
|
||||
n.label = it.label;
|
||||
n.path = "ImGui/" + it.window_name + "/" + it.label;
|
||||
n.id = n.path; // imgui items use their path as id in v1
|
||||
n.enabled = it.enabled;
|
||||
n.visible = true;
|
||||
n.has_value = it.has_value;
|
||||
n.value = it.value;
|
||||
n.rect = { origin.x + int(it.x / scale), origin.y + int(it.y / scale),
|
||||
int(it.w / scale), int(it.h / scale) };
|
||||
root.children.push_back(std::move(n));
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void WxUiBackend::refresh_ui() {
|
||||
run_on_gui(m_gui_timeout_ms, [] {
|
||||
// Force a fresh ImGui frame so transient items are recorded, then flush
|
||||
// pending events so the latest frame is the one we read.
|
||||
if (Plater* p = wxGetApp().plater()) {
|
||||
if (GLCanvas3D* canvas = p->get_current_canvas3D()) {
|
||||
canvas->set_as_dirty();
|
||||
canvas->render();
|
||||
}
|
||||
}
|
||||
wxGetApp().Yield();
|
||||
});
|
||||
}
|
||||
|
||||
UiNode WxUiBackend::dump_tree(const DumpOptions& opts) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&opts]() -> UiNode {
|
||||
wxWindow* root_win = nullptr;
|
||||
if (opts.root) root_win = window_for_automation_id(*opts.root);
|
||||
if (root_win == nullptr)
|
||||
root_win = static_cast<wxWindow*>(wxGetApp().mainframe);
|
||||
UiNode root;
|
||||
if (root_win) build_node(root_win, root, {}, 0, opts, 0);
|
||||
if (opts.include_imgui) append_imgui_nodes(root);
|
||||
return root;
|
||||
});
|
||||
}
|
||||
|
||||
AppState WxUiBackend::app_state() {
|
||||
return run_on_gui(m_gui_timeout_ms, []() -> AppState {
|
||||
AppState s;
|
||||
MainFrame* mf = wxGetApp().mainframe;
|
||||
Plater* p = wxGetApp().plater();
|
||||
if (mf) {
|
||||
// best-effort: MainFrame has no public getter for the selected top tab
|
||||
// (m_tabpanel is private), so report the frame title for now.
|
||||
s.active_tab = std::string(mf->GetTitle().ToUTF8());
|
||||
s.foreground = mf->IsActive();
|
||||
}
|
||||
if (p) {
|
||||
s.project_loaded = !p->model().objects.empty();
|
||||
s.slicing = p->is_background_process_slicing();
|
||||
}
|
||||
if (wxWindow* top = wxGetActiveWindow())
|
||||
if (auto* dlg = dynamic_cast<wxDialog*>(top))
|
||||
if (dlg != static_cast<wxWindow*>(mf) && dlg->IsModal())
|
||||
s.modal_dialog = std::string(dlg->GetTitle().ToUTF8());
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input helpers (anonymous namespace)
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
// Map a normalized key name (single char, or "enter"/"tab"/"f5"/...) to a wx
|
||||
// keycode. Returns 0 when unmapped (caller skips it).
|
||||
long wx_keycode(const std::string& key) {
|
||||
if (key.size() == 1) return (long)std::toupper((unsigned char)key[0]);
|
||||
if (key == "enter" || key == "return") return WXK_RETURN;
|
||||
if (key == "tab") return WXK_TAB;
|
||||
if (key == "esc" || key == "escape") return WXK_ESCAPE;
|
||||
if (key == "space") return WXK_SPACE;
|
||||
if (key == "delete") return WXK_DELETE;
|
||||
if (key == "backspace") return WXK_BACK;
|
||||
if (key.size() >= 2 && (key[0] == 'f' || key[0] == 'F')) {
|
||||
int n = std::atoi(key.c_str() + 1);
|
||||
if (n >= 1 && n <= 12) return WXK_F1 + (n - 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Press (down==true) or release (down==false) the modifier keys. Cmd maps to
|
||||
// Ctrl on the platforms we drive here.
|
||||
void apply_modifiers_down(wxUIActionSimulator& sim,
|
||||
const std::vector<KeyModifier>& mods, bool down) {
|
||||
for (KeyModifier m : mods) {
|
||||
long code = (m == KeyModifier::Ctrl) ? WXK_CONTROL :
|
||||
(m == KeyModifier::Shift) ? WXK_SHIFT :
|
||||
(m == KeyModifier::Alt) ? WXK_ALT : WXK_CONTROL; // Cmd~Ctrl
|
||||
if (down) sim.KeyDown((int)code); else sim.KeyUp((int)code);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool WxUiBackend::click(const UiNode& node, MouseButton button, bool dbl,
|
||||
const std::vector<KeyModifier>& modifiers) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> bool {
|
||||
// Raise/focus the owning top-level window so OS input lands on it.
|
||||
if (auto* w = reinterpret_cast<wxWindow*>(node.handle)) {
|
||||
if (wxWindow* tlw = wxGetTopLevelParent(w)) tlw->Raise();
|
||||
w->SetFocus();
|
||||
}
|
||||
const int cx = node.rect.x + node.rect.w / 2;
|
||||
const int cy = node.rect.y + node.rect.h / 2;
|
||||
wxUIActionSimulator sim;
|
||||
sim.MouseMove(cx, cy);
|
||||
apply_modifiers_down(sim, modifiers, true);
|
||||
const int b = (button == MouseButton::Right) ? wxMOUSE_BTN_RIGHT :
|
||||
(button == MouseButton::Middle) ? wxMOUSE_BTN_MIDDLE :
|
||||
wxMOUSE_BTN_LEFT;
|
||||
if (dbl) sim.MouseDblClick(b); else sim.MouseClick(b);
|
||||
apply_modifiers_down(sim, modifiers, false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool WxUiBackend::type_text(const std::string& text) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> bool {
|
||||
wxUIActionSimulator sim;
|
||||
// wxUIActionSimulator::Text takes a const char*; `text` is already UTF-8.
|
||||
sim.Text(text.c_str());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool WxUiBackend::send_keys(const std::vector<KeyChord>& chords) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> bool {
|
||||
wxUIActionSimulator sim;
|
||||
for (const KeyChord& c : chords) {
|
||||
const long code = wx_keycode(c.key);
|
||||
if (code == 0) continue;
|
||||
apply_modifiers_down(sim, c.modifiers, true);
|
||||
sim.Char((int)code);
|
||||
apply_modifiers_down(sim, c.modifiers, false);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screenshot helpers (anonymous namespace)
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
PngImage wximage_to_png(const wxImage& image) {
|
||||
wxMemoryOutputStream mem;
|
||||
if (!image.SaveFile(mem, wxBITMAP_TYPE_PNG))
|
||||
throw AutomationError(kErrScreenshotFail, "PNG encode failed");
|
||||
PngImage out;
|
||||
out.width = image.GetWidth();
|
||||
out.height = image.GetHeight();
|
||||
const size_t n = mem.GetSize();
|
||||
out.png.resize(n);
|
||||
mem.CopyTo(out.png.data(), n);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PngImage WxUiBackend::screenshot_window(const UiNode* target) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> PngImage {
|
||||
wxWindow* win = target ? reinterpret_cast<wxWindow*>(target->handle)
|
||||
: static_cast<wxWindow*>(wxGetApp().mainframe);
|
||||
if (win == nullptr)
|
||||
throw AutomationError(kErrScreenshotFail, "no window to capture");
|
||||
const wxSize sz = win->GetClientSize();
|
||||
if (sz.x <= 0 || sz.y <= 0)
|
||||
throw AutomationError(kErrScreenshotFail, "window has no client area");
|
||||
// Capture from the on-screen (DWM-composited) framebuffer rather than the
|
||||
// window's own client DC. A parent client DC clips out child HWNDs, so all of
|
||||
// OrcaSlicer's custom child-window controls (sidebar buttons/combos/panels) and
|
||||
// the GL canvas come back as uninitialized (black) bitmap memory. wxScreenDC
|
||||
// reads the composited desktop, which includes every child window, the OpenGL
|
||||
// surface, and ImGui overlays — pixel-perfect to what the user sees.
|
||||
//
|
||||
// Requirement: the window must be visible and unobscured (see doc/automation.md
|
||||
// §platform caveats); the backend raises it before injecting input anyway.
|
||||
// HiDPI note: GetClientSize is in logical units while wxScreenDC is in physical
|
||||
// pixels; on per-monitor-DPI setups the captured size may differ from the logical
|
||||
// client size (documented caveat, acceptable for v1).
|
||||
win->Raise();
|
||||
const wxPoint origin = win->ClientToScreen(wxPoint(0, 0));
|
||||
wxBitmap bmp(sz.x, sz.y);
|
||||
wxScreenDC sdc;
|
||||
wxMemoryDC mdc(bmp);
|
||||
mdc.Blit(0, 0, sz.x, sz.y, &sdc, origin.x, origin.y);
|
||||
mdc.SelectObject(wxNullBitmap);
|
||||
return wximage_to_png(bmp.ConvertToImage());
|
||||
});
|
||||
}
|
||||
|
||||
int WxUiBackend::open_files(const std::vector<std::string>& paths) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> int {
|
||||
Plater* plater = wxGetApp().plater();
|
||||
if (plater == nullptr)
|
||||
throw AutomationError(kErrLoadFailed, "no plater to load into");
|
||||
// Default strategy matches drag-drop / Plater::load_files's own default: it
|
||||
// routes .3mf as a project and meshes as models based on file content, so no
|
||||
// as_project flag is needed in v1. ask_multi=false: never prompt.
|
||||
const LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig;
|
||||
std::vector<size_t> loaded;
|
||||
try {
|
||||
loaded = plater->load_files(paths, strategy, /*ask_multi=*/false);
|
||||
} catch (const std::exception& e) {
|
||||
throw AutomationError(kErrLoadFailed,
|
||||
std::string("load_files failed: ") + e.what());
|
||||
}
|
||||
if (loaded.empty())
|
||||
throw AutomationError(kErrLoadFailed, "load_files loaded nothing");
|
||||
return static_cast<int>(loaded.size());
|
||||
});
|
||||
}
|
||||
|
||||
int WxUiBackend::select_view(const std::string& view) {
|
||||
return run_on_gui(m_gui_timeout_ms, [&]() -> int {
|
||||
MainFrame* mainframe = wxGetApp().mainframe;
|
||||
if (mainframe == nullptr)
|
||||
throw AutomationError(kErrNotFound, "no main frame to select a view in");
|
||||
const int index = mainframe->select_tab_by_name(view);
|
||||
if (index < 0)
|
||||
throw AutomationError(kErrNotFound,
|
||||
std::string("view not available: ") + view);
|
||||
return index;
|
||||
});
|
||||
}
|
||||
|
||||
}}} // namespace Slic3r::GUI::Automation
|
||||
29
src/slic3r/GUI/Automation/WxUiBackend.hpp
Normal file
29
src/slic3r/GUI/Automation/WxUiBackend.hpp
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include "IUiBackend.hpp"
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// Real backend. Every public method marshals its work onto the GUI thread via
|
||||
// wxGetApp().CallAfter + a std::future with a per-call timeout (error kErrGuiBusy on
|
||||
// timeout). Walks the wxWindow tree, reads the ImGui item table, drives
|
||||
// wxUIActionSimulator, captures screenshots.
|
||||
class WxUiBackend : public IUiBackend {
|
||||
public:
|
||||
explicit WxUiBackend(int gui_timeout_ms = 5000) : m_gui_timeout_ms(gui_timeout_ms) {}
|
||||
|
||||
void refresh_ui() override;
|
||||
UiNode dump_tree(const DumpOptions& opts) override;
|
||||
AppState app_state() override;
|
||||
bool click(const UiNode& node, MouseButton button, bool dbl,
|
||||
const std::vector<KeyModifier>& modifiers) override;
|
||||
bool type_text(const std::string& text) override;
|
||||
bool send_keys(const std::vector<KeyChord>& chords) override;
|
||||
PngImage screenshot_window(const UiNode* target) override;
|
||||
int open_files(const std::vector<std::string>& paths) override;
|
||||
int select_view(const std::string& view) override;
|
||||
|
||||
private:
|
||||
int m_gui_timeout_ms;
|
||||
};
|
||||
|
||||
}}} // namespace Slic3r::GUI::Automation
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "GUI_ObjectList.hpp"
|
||||
#include "slic3r/GUI/UserManager.hpp"
|
||||
#include "slic3r/GUI/TaskManager.hpp"
|
||||
#include "slic3r/GUI/Automation/AutomationServer.hpp"
|
||||
#include "slic3r/GUI/Automation/WxUiBackend.hpp"
|
||||
#include "slic3r/GUI/Automation/JsonRpcDispatcher.hpp"
|
||||
#include "format.hpp"
|
||||
#include "libslic3r_version.h"
|
||||
#include "Downloader.hpp"
|
||||
@@ -730,6 +733,12 @@ void GUI_App::post_init()
|
||||
if (! this->initialized())
|
||||
throw Slic3r::RuntimeError("Calling post_init() while not yet initialized");
|
||||
|
||||
// UI automation: start the localhost server only when --automation-server set a port.
|
||||
if (this->init_params != nullptr && this->init_params->automation_port > 0) {
|
||||
m_automation_port = this->init_params->automation_port;
|
||||
start_automation_server();
|
||||
}
|
||||
|
||||
m_open_method = "double_click";
|
||||
bool switch_to_3d = false;
|
||||
|
||||
@@ -2464,6 +2473,7 @@ bool GUI_App::OnInit()
|
||||
int GUI_App::OnExit()
|
||||
{
|
||||
stop_http_server();
|
||||
stop_automation_server();
|
||||
stop_sync_user_preset();
|
||||
|
||||
if (m_device_manager) {
|
||||
@@ -7082,6 +7092,33 @@ void GUI_App::stop_http_server()
|
||||
m_http_server.stop();
|
||||
}
|
||||
|
||||
void GUI_App::start_automation_server()
|
||||
{
|
||||
if (m_automation_port <= 0) return; // disabled
|
||||
if (m_automation_server) return; // already running
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
m_automation_backend.reset(new WxUiBackend());
|
||||
m_automation_dispatcher.reset(new JsonRpcDispatcher(*m_automation_backend));
|
||||
m_automation_server.reset(new AutomationServer((unsigned short)m_automation_port));
|
||||
JsonRpcDispatcher* disp = m_automation_dispatcher.get();
|
||||
m_automation_server->set_handler(
|
||||
[disp](const std::string& body) { return disp->handle_request(body); });
|
||||
m_automation_server->set_health_text(
|
||||
std::string("OrcaSlicer automation server v") + kAutomationVersion);
|
||||
m_automation_server->start();
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "UI automation server ENABLED on 127.0.0.1:" << m_automation_port
|
||||
<< " (input injection is active)";
|
||||
}
|
||||
|
||||
void GUI_App::stop_automation_server()
|
||||
{
|
||||
if (m_automation_server) m_automation_server->stop();
|
||||
m_automation_server.reset();
|
||||
m_automation_dispatcher.reset();
|
||||
m_automation_backend.reset();
|
||||
}
|
||||
|
||||
void GUI_App::switch_staff_pick(bool on)
|
||||
{
|
||||
mainframe->m_webview->SendDesignStaffpick(on);
|
||||
|
||||
@@ -86,6 +86,14 @@ class ModelMallDialog;
|
||||
class PingCodeBindDialog;
|
||||
class NetworkErrorDialog;
|
||||
|
||||
// UI automation (opt-in). Forward declarations so GUI_App can own the stack
|
||||
// by unique_ptr without pulling the Automation headers into this header.
|
||||
namespace Automation {
|
||||
class AutomationServer;
|
||||
class WxUiBackend;
|
||||
class JsonRpcDispatcher;
|
||||
}
|
||||
|
||||
|
||||
enum FileType
|
||||
{
|
||||
@@ -238,6 +246,7 @@ private:
|
||||
bool m_initialized { false };
|
||||
bool m_post_initialized { false };
|
||||
bool m_app_conf_exists{ false };
|
||||
int m_automation_port { 0 }; // UI automation: 0 = off; set by Task 17 from CLI
|
||||
EAppMode m_app_mode{ EAppMode::Editor };
|
||||
bool m_is_recreating_gui{ false };
|
||||
#ifdef __linux__
|
||||
@@ -334,6 +343,11 @@ private:
|
||||
HttpServer m_http_server;
|
||||
bool m_show_gcode_window{true};
|
||||
boost::thread m_check_network_thread;
|
||||
|
||||
// --- UI automation (opt-in; off unless --automation-server) ---
|
||||
std::unique_ptr<Slic3r::GUI::Automation::AutomationServer> m_automation_server;
|
||||
std::unique_ptr<Slic3r::GUI::Automation::WxUiBackend> m_automation_backend;
|
||||
std::unique_ptr<Slic3r::GUI::Automation::JsonRpcDispatcher> m_automation_dispatcher;
|
||||
public:
|
||||
//try again when subscription fails
|
||||
void on_start_subscribe_again(std::string dev_id);
|
||||
@@ -364,6 +378,16 @@ public:
|
||||
FilamentColorCodeQuery* get_filament_color_code_query();
|
||||
bool is_editor() const { return m_app_mode == EAppMode::Editor; }
|
||||
bool is_gcode_viewer() const { return m_app_mode == EAppMode::GCodeViewer; }
|
||||
|
||||
// UI automation: true once a TCP port has been assigned (via CLI). When false,
|
||||
// every automation recording hook short-circuits to a single bool check.
|
||||
// NOTE: Task 17 extends the automation lifecycle (server/backend) around this
|
||||
// accessor; here we only add the minimal flag/accessor the hooks depend on.
|
||||
bool is_automation_enabled() const { return m_automation_port > 0; }
|
||||
// UI automation lifecycle: wires WxUiBackend -> JsonRpcDispatcher -> AutomationServer
|
||||
// and starts/stops the localhost server. Both no-op when automation is off.
|
||||
void start_automation_server();
|
||||
void stop_automation_server();
|
||||
bool is_recreating_gui() const { return m_is_recreating_gui; }
|
||||
std::string logo_name() const { return is_editor() ? "OrcaSlicer" : "OrcaSlicer-gcodeviewer"; }
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ struct GUI_InitParams
|
||||
//BBS: remove start_as_gcodeviewer logic
|
||||
//bool start_as_gcodeviewer;
|
||||
bool input_gcode { false };
|
||||
|
||||
// UI automation: 0 = disabled, else the TCP port for the localhost JSON-RPC server.
|
||||
int automation_port { 0 };
|
||||
};
|
||||
|
||||
int GUI_Run(GUI_InitParams ¶ms);
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
#include "Search.hpp"
|
||||
#include "BitmapCache.hpp"
|
||||
#include "GUI_App.hpp"
|
||||
#include "slic3r/GUI/Automation/ImGuiItemTable.hpp"
|
||||
|
||||
#include "../Utils/MacDarkMode.hpp"
|
||||
#include <nanosvg/nanosvg.h>
|
||||
@@ -570,11 +571,44 @@ void ImGuiWrapper::new_frame()
|
||||
// BBL: end copy & paste
|
||||
}
|
||||
|
||||
void ImGuiWrapper::automation_record_last_item(const char* type, const std::string& label,
|
||||
bool has_value, const std::string& value) {
|
||||
if (!wxGetApp().is_automation_enabled())
|
||||
return;
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
const ImVec2 mn = ImGui::GetItemRectMin();
|
||||
const ImVec2 mx = ImGui::GetItemRectMax();
|
||||
ImGuiItemRecord rec;
|
||||
ImGuiContext* ctx = ImGui::GetCurrentContext();
|
||||
rec.window_name = (ctx && ctx->CurrentWindow) ? ctx->CurrentWindow->Name : "";
|
||||
rec.label = label;
|
||||
rec.type = type;
|
||||
rec.x = mn.x; rec.y = mn.y; rec.w = mx.x - mn.x; rec.h = mx.y - mn.y;
|
||||
rec.enabled = true; // v1: precise per-item disabled-state read is non-trivial across ImGui versions
|
||||
rec.has_value = has_value;
|
||||
rec.value = value;
|
||||
ImGuiItemTable::instance().record_item(std::move(rec));
|
||||
}
|
||||
|
||||
void ImGuiWrapper::render()
|
||||
{
|
||||
ImGui::Render();
|
||||
render_draw_data(ImGui::GetDrawData());
|
||||
m_new_frame_open = false;
|
||||
|
||||
if (wxGetApp().is_automation_enabled()) {
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
ImGuiContext& g = *ImGui::GetCurrentContext();
|
||||
for (ImGuiWindow* w : g.Windows) {
|
||||
if (w == nullptr) continue;
|
||||
ImGuiWindowRecord wr;
|
||||
wr.name = w->Name ? w->Name : "";
|
||||
wr.x = w->Pos.x; wr.y = w->Pos.y; wr.w = w->Size.x; wr.h = w->Size.y;
|
||||
wr.visible = w->Active && !w->Hidden;
|
||||
ImGuiItemTable::instance().record_window(std::move(wr));
|
||||
}
|
||||
ImGuiItemTable::instance().swap_frame();
|
||||
}
|
||||
}
|
||||
|
||||
ImVec2 ImGuiWrapper::calc_text_size(std::string_view text,
|
||||
@@ -870,6 +904,7 @@ bool ImGuiWrapper::button(const wxString &label, const wxString& tooltip)
|
||||
{
|
||||
auto label_utf8 = into_u8(label);
|
||||
const bool ret = ImGui::Button(label_utf8.c_str());
|
||||
automation_record_last_item("button", label_utf8, false, {});
|
||||
|
||||
if (!tooltip.IsEmpty() && ImGui::IsItemHovered()) {
|
||||
const float max_tooltip_width = ImGui::GetFontSize() * 20.0f;
|
||||
@@ -883,6 +918,7 @@ bool ImGuiWrapper::bbl_button(const wxString &label, const wxString& tooltip)
|
||||
{
|
||||
auto label_utf8 = into_u8(label);
|
||||
const bool ret = ImGui::BBLButton(label_utf8.c_str());
|
||||
automation_record_last_item("button", label_utf8, false, {});
|
||||
|
||||
if (!tooltip.IsEmpty() && ImGui::IsItemHovered()) {
|
||||
const float max_tooltip_width = ImGui::GetFontSize() * 20.0f;
|
||||
@@ -960,7 +996,9 @@ bool ImGuiWrapper::glyph_button(wchar_t icon_char, ImVec2 icon_size)
|
||||
bool ImGuiWrapper::radio_button(const wxString &label, bool active)
|
||||
{
|
||||
auto label_utf8 = into_u8(label);
|
||||
return ImGui::RadioButton(label_utf8.c_str(), active);
|
||||
const bool ret = ImGui::RadioButton(label_utf8.c_str(), active);
|
||||
automation_record_last_item("radio", label_utf8, true, active ? "true" : "false");
|
||||
return ret;
|
||||
}
|
||||
|
||||
ImVec4 ImGuiWrapper::to_ImVec4(const ColorRGB &color) {
|
||||
@@ -969,7 +1007,11 @@ ImVec4 ImGuiWrapper::to_ImVec4(const ColorRGB &color) {
|
||||
|
||||
bool ImGuiWrapper::input_double(const std::string &label, const double &value, const std::string &format)
|
||||
{
|
||||
return ImGui::InputDouble(label.c_str(), const_cast<double*>(&value), 0.0f, 0.0f, format.c_str(), ImGuiInputTextFlags_CharsDecimal);
|
||||
const bool ret = ImGui::InputDouble(label.c_str(), const_cast<double*>(&value), 0.0f, 0.0f, format.c_str(), ImGuiInputTextFlags_CharsDecimal);
|
||||
char value_buf[64];
|
||||
snprintf(value_buf, sizeof(value_buf), format.c_str(), value);
|
||||
automation_record_last_item("input", label, true, value_buf);
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::input_double(const wxString &label, const double &value, const std::string &format)
|
||||
@@ -1000,7 +1042,9 @@ bool ImGuiWrapper::input_vec3(const std::string &label, const Vec3d &value, floa
|
||||
bool ImGuiWrapper::checkbox(const wxString &label, bool &value)
|
||||
{
|
||||
auto label_utf8 = into_u8(label);
|
||||
return ImGui::Checkbox(label_utf8.c_str(), &value);
|
||||
const bool ret = ImGui::Checkbox(label_utf8.c_str(), &value);
|
||||
automation_record_last_item("checkbox", label_utf8, true, value ? "true" : "false");
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool ImGuiWrapper::bbl_checkbox(const wxString &label, bool &value)
|
||||
@@ -1014,6 +1058,7 @@ bool ImGuiWrapper::bbl_checkbox(const wxString &label, bool &value)
|
||||
}
|
||||
auto label_utf8 = into_u8(label);
|
||||
result = ImGui::BBLCheckbox(label_utf8.c_str(), &value);
|
||||
automation_record_last_item("checkbox", label_utf8, true, value ? "true" : "false");
|
||||
|
||||
if (b_value) { ImGui::PopStyleColor(3);}
|
||||
return result;
|
||||
@@ -1147,6 +1192,11 @@ bool ImGuiWrapper::slider_float(const char* label, float* v, float v_min, float
|
||||
str_label = str_label.substr(0, pos) + str_label.substr(pos + 2);
|
||||
|
||||
bool ret = ImGui::SliderFloat(str_label.c_str(), v, v_min, v_max, format, power);
|
||||
{
|
||||
char value_buf[64];
|
||||
snprintf(value_buf, sizeof(value_buf), format, v ? *v : 0.0f);
|
||||
automation_record_last_item("slider", label, true, value_buf);
|
||||
}
|
||||
|
||||
m_last_slider_status.hovered = ImGui::IsItemHovered();
|
||||
m_last_slider_status.edited = ImGui::IsItemEdited();
|
||||
@@ -1324,6 +1374,10 @@ bool ImGuiWrapper::combo(const std::string& label, const std::vector<std::string
|
||||
}
|
||||
|
||||
selection = selection_out;
|
||||
{
|
||||
const std::string current_value = (selection >= 0 && selection < int(options.size())) ? options[selection] : std::string();
|
||||
automation_record_last_item("combo", label, true, current_value);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -386,6 +386,11 @@ private:
|
||||
static const char* clipboard_get(void* user_data);
|
||||
static void clipboard_set(void* user_data, const char* text);
|
||||
|
||||
// Automation recording: appends the most-recently-drawn ImGui item to the
|
||||
// automation item table. No-op (single bool check) when automation is disabled.
|
||||
void automation_record_last_item(const char* type, const std::string& label,
|
||||
bool has_value, const std::string& value);
|
||||
|
||||
LastSliderStatus m_last_slider_status;
|
||||
ImFont* default_font = nullptr;
|
||||
ImFont* bold_font = nullptr;
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
#include "GUI_App.hpp"
|
||||
#include "UnsavedChangesDialog.hpp"
|
||||
#include "MsgDialog.hpp"
|
||||
#include "Automation/AutomationRegistry.hpp"
|
||||
#include "Notebook.hpp"
|
||||
#include "GUI_Factories.hpp"
|
||||
#include "GUI_ObjectList.hpp"
|
||||
@@ -1326,6 +1327,7 @@ void MainFrame::init_tabpanel() {
|
||||
//BBS add pages
|
||||
m_monitor = new MonitorPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize);
|
||||
m_monitor->SetBackgroundColour(*wxWHITE);
|
||||
Slic3r::GUI::Automation::set_automation_id(m_monitor, "tab_device");
|
||||
m_tabpanel->AddPage(m_monitor, _L("Device"), std::string("tab_monitor_active"), std::string("tab_monitor_active"), false);
|
||||
|
||||
m_printer_view = new PrinterWebView(m_tabpanel);
|
||||
@@ -1384,6 +1386,7 @@ void MainFrame::show_device(bool bBBLPrinter) {
|
||||
if (!m_monitor) {
|
||||
m_monitor = new MonitorPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize);
|
||||
m_monitor->SetBackgroundColour(*wxWHITE);
|
||||
Slic3r::GUI::Automation::set_automation_id(m_monitor, "tab_device");
|
||||
}
|
||||
m_monitor->Show(false);
|
||||
m_tabpanel->InsertPage(tpMonitor, m_monitor, _L("Device"), std::string("tab_monitor_active"), std::string("tab_monitor_active"));
|
||||
@@ -1833,6 +1836,10 @@ wxBoxSizer* MainFrame::create_side_tools()
|
||||
m_slice_option_btn = new SideButton(slice_panel, "", "sidebutton_dropdown", 0, 14);
|
||||
m_print_btn = new SideButton(print_panel, _L("Print plate"), "");
|
||||
m_print_option_btn = new SideButton(print_panel, "", "sidebutton_dropdown", 0, 14);
|
||||
// Stable automation ids for external scripts (safe no-op when automation is disabled).
|
||||
// m_print_btn doubles as the export-G-code action depending on m_print_select.
|
||||
Slic3r::GUI::Automation::set_automation_id(m_slice_btn, "btn_slice");
|
||||
Slic3r::GUI::Automation::set_automation_id(m_print_btn, "btn_export");
|
||||
|
||||
auto slice_sizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
slice_sizer->Add(m_slice_option_btn, 0, wxRIGHT | wxALIGN_CENTER_VERTICAL, FromDIP(1));
|
||||
@@ -3876,6 +3883,40 @@ void MainFrame::select_tab(wxPanel* panel)
|
||||
select_tab(size_t(page_idx));
|
||||
}
|
||||
|
||||
int MainFrame::select_tab_by_name(const std::string& name)
|
||||
{
|
||||
// Resolve every view by its page window via FindPage, so the mapping stays
|
||||
// correct when optional tabs (e.g. Multi-device) shift raw indices, and
|
||||
// self-disables (returns -1) in layouts where a page is absent (e.g.
|
||||
// GCodeViewer mode has no plater page). Prepare and Preview share m_plater:
|
||||
// it is inserted as two adjacent pages, so Prepare is FindPage(m_plater) and
|
||||
// Preview is the next index.
|
||||
wxWindow* page = nullptr;
|
||||
int offset = 0;
|
||||
if (name == "prepare") { page = m_plater; offset = 0; }
|
||||
else if (name == "preview") { page = m_plater; offset = 1; }
|
||||
else if (name == "home") page = m_webview;
|
||||
else if (name == "device")
|
||||
// The Device tab is m_monitor for Bambu printers but m_printer_view for
|
||||
// third-party printers (show_device swaps them in place), so pick whichever
|
||||
// is currently paged.
|
||||
page = (m_monitor != nullptr && m_tabpanel->FindPage(m_monitor) != wxNOT_FOUND)
|
||||
? static_cast<wxWindow*>(m_monitor)
|
||||
: static_cast<wxWindow*>(m_printer_view);
|
||||
else if (name == "multi_device") page = m_multi_machine;
|
||||
else if (name == "project") page = m_project;
|
||||
else if (name == "calibration") page = m_calibration;
|
||||
else return -1; // unknown view name
|
||||
|
||||
if (page == nullptr) return -1; // view not available in this layout
|
||||
const int found = m_tabpanel->FindPage(page);
|
||||
if (found == wxNOT_FOUND) return -1;
|
||||
const int idx = found + offset;
|
||||
if (idx < 0 || idx >= (int)m_tabpanel->GetPageCount()) return -1;
|
||||
select_tab(size_t(idx));
|
||||
return m_tabpanel->GetSelection();
|
||||
}
|
||||
|
||||
//BBS
|
||||
void MainFrame::jump_to_monitor(std::string dev_id)
|
||||
{
|
||||
|
||||
@@ -327,6 +327,14 @@ public:
|
||||
//BBS: GUI refactor
|
||||
void select_tab(wxPanel* panel);
|
||||
void select_tab(size_t tab = size_t(-1));
|
||||
// Automation: select a top-level tab by stable name ("home", "prepare",
|
||||
// "preview", "device", "multi_device", "project", "calibration"). Returns the
|
||||
// resulting notebook page index, or -1 if that view is unavailable in the
|
||||
// current layout. Robust to conditionally-present tabs (e.g. Multi-device) that
|
||||
// shift raw indices: every view selects by its page window via FindPage.
|
||||
// Prepare/Preview share m_plater (inserted as two adjacent pages), so Prepare
|
||||
// is FindPage(m_plater) and Preview is the next index.
|
||||
int select_tab_by_name(const std::string& name);
|
||||
void request_select_tab(TabPosition pos);
|
||||
int get_calibration_curr_tab();
|
||||
void select_view(const std::string& direction);
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
#include "NotificationManager.hpp"
|
||||
#include "PresetComboBoxes.hpp"
|
||||
#include "MsgDialog.hpp"
|
||||
#include "Automation/AutomationRegistry.hpp"
|
||||
#include "ProjectDirtyStateManager.hpp"
|
||||
#include "Gizmos/GLGizmoSimplify.hpp" // create suggestion notification
|
||||
#include "Gizmos/GLGizmoSVG.hpp" // Drop SVG file
|
||||
@@ -1768,6 +1769,7 @@ Sidebar::Sidebar(Plater *parent)
|
||||
});
|
||||
|
||||
p->combo_printer = new PlaterPresetComboBox(p->panel_printer_preset, Preset::TYPE_PRINTER);
|
||||
Slic3r::GUI::Automation::set_automation_id(p->combo_printer, "combo_printer");
|
||||
p->combo_printer->SetBorderWidth(0);
|
||||
p->combo_printer->SetMaxSize(wxSize(-1, FromDIP(30))); // limiting height makes badge visible
|
||||
// ORCA paint whole combobox on focus
|
||||
@@ -2167,6 +2169,7 @@ Sidebar::Sidebar(Plater *parent)
|
||||
|
||||
/* first filament item */
|
||||
init_filament_combo(&p->combos_filament[0], 0);
|
||||
Slic3r::GUI::Automation::set_automation_id(p->combos_filament[0], "combo_filament");
|
||||
|
||||
//bSizer_filament_content->Add(p->sizer_filaments, 1, wxALIGN_CENTER | wxALL);
|
||||
wxSizer *sizer_filaments2 = new wxBoxSizer(wxVERTICAL);
|
||||
@@ -5062,6 +5065,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
|
||||
}
|
||||
|
||||
wxGLCanvas* view3D_canvas = view3D->get_wxglcanvas();
|
||||
Slic3r::GUI::Automation::set_automation_id(view3D_canvas, "canvas_3d");
|
||||
//BBS: GUI refactor
|
||||
wxGLCanvas* preview_canvas = preview->get_wxglcanvas();
|
||||
|
||||
|
||||
@@ -52,5 +52,6 @@ add_subdirectory(libslic3r)
|
||||
add_subdirectory(slic3rutils)
|
||||
add_subdirectory(fff_print)
|
||||
add_subdirectory(sla_print)
|
||||
add_subdirectory(automation)
|
||||
|
||||
|
||||
|
||||
21
tests/automation/CMakeLists.txt
Normal file
21
tests/automation/CMakeLists.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
automation_tests.cpp
|
||||
test_serializer.cpp
|
||||
test_locator.cpp
|
||||
test_dispatcher.cpp
|
||||
MockUiBackend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/WidgetSerializer.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/Locator.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp
|
||||
)
|
||||
target_link_libraries(${_TEST_NAME}_tests test_common Catch2::Catch2WithMain nlohmann_json)
|
||||
# The nlohmann_json INTERFACE target only exposes deps_src/nlohmann on the include
|
||||
# path (so <json.hpp> resolves). The rest of the codebase reaches <nlohmann/json.hpp>
|
||||
# via the deps_src root that admesh re-exports transitively; this pure unit-test
|
||||
# target does not link admesh, so add the deps_src root explicitly to match.
|
||||
target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/deps_src)
|
||||
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
|
||||
orcaslicer_copy_test_dlls()
|
||||
catch_discover_tests(${_TEST_NAME}_tests)
|
||||
13
tests/automation/MockUiBackend.cpp
Normal file
13
tests/automation/MockUiBackend.cpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#include "MockUiBackend.hpp"
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
|
||||
TEST_CASE("MockUiBackend records calls", "[automation][mock]") {
|
||||
MockUiBackend mock;
|
||||
UiNode n; n.id = "btn_slice";
|
||||
mock.click(n, MouseButton::Left, false, {});
|
||||
REQUIRE(mock.clicked_ids.size() == 1);
|
||||
CHECK(mock.clicked_ids[0] == "btn_slice");
|
||||
CHECK(mock.click_buttons[0] == MouseButton::Left);
|
||||
}
|
||||
73
tests/automation/MockUiBackend.hpp
Normal file
73
tests/automation/MockUiBackend.hpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include "slic3r/GUI/Automation/IUiBackend.hpp"
|
||||
#include "slic3r/GUI/Automation/JsonRpcDispatcher.hpp" // kErrLoadFailed
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r { namespace GUI { namespace Automation {
|
||||
|
||||
// Deterministic fake backend for dispatcher tests. Records every primitive call
|
||||
// and returns canned data. `tree_provider` lets a test return different trees on
|
||||
// successive dump_tree() calls (used for sync.wait_for tests).
|
||||
class MockUiBackend : public IUiBackend {
|
||||
public:
|
||||
// Recorded calls (inspected by tests).
|
||||
int refresh_count = 0;
|
||||
int dump_count = 0;
|
||||
std::vector<std::string> clicked_ids; // node.id of each click()
|
||||
std::vector<MouseButton> click_buttons;
|
||||
std::vector<std::string> typed_text;
|
||||
std::vector<std::vector<KeyChord>> sent_keys;
|
||||
int screenshot_window_count = 0;
|
||||
std::vector<std::vector<std::string>> opened_paths; // paths of each open_files()
|
||||
std::vector<std::string> selected_views; // view of each select_view()
|
||||
|
||||
// Canned outputs (set by tests).
|
||||
UiNode tree; // default tree for dump_tree
|
||||
AppState state;
|
||||
PngImage canned_png{ {0x89,0x50,0x4E,0x47}, 4, 4 }; // fake "PNG" bytes
|
||||
bool click_result = true;
|
||||
int open_return_count = 0; // value open_files() returns
|
||||
bool open_should_fail = false; // when true, open_files() throws kErrLoadFailed
|
||||
int select_view_index = 0; // value select_view() returns
|
||||
bool select_view_should_fail = false; // when true, select_view() throws kErrNotFound
|
||||
|
||||
// Optional: per-call tree provider (overrides `tree` when set).
|
||||
std::function<UiNode(int /*call_index*/)> tree_provider;
|
||||
|
||||
void refresh_ui() override { ++refresh_count; }
|
||||
UiNode dump_tree(const DumpOptions&) override {
|
||||
const int idx = dump_count++;
|
||||
return tree_provider ? tree_provider(idx) : tree;
|
||||
}
|
||||
AppState app_state() override { return state; }
|
||||
bool click(const UiNode& node, MouseButton button, bool /*dbl*/,
|
||||
const std::vector<KeyModifier>&) override {
|
||||
clicked_ids.push_back(node.id);
|
||||
click_buttons.push_back(button);
|
||||
return click_result;
|
||||
}
|
||||
bool type_text(const std::string& text) override {
|
||||
typed_text.push_back(text); return true;
|
||||
}
|
||||
bool send_keys(const std::vector<KeyChord>& chords) override {
|
||||
sent_keys.push_back(chords); return true;
|
||||
}
|
||||
PngImage screenshot_window(const UiNode*) override {
|
||||
++screenshot_window_count; return canned_png;
|
||||
}
|
||||
int open_files(const std::vector<std::string>& paths) override {
|
||||
opened_paths.push_back(paths);
|
||||
if (open_should_fail)
|
||||
throw AutomationError(kErrLoadFailed, "mock load failed");
|
||||
return open_return_count;
|
||||
}
|
||||
int select_view(const std::string& view) override {
|
||||
selected_views.push_back(view);
|
||||
if (select_view_should_fail)
|
||||
throw AutomationError(kErrNotFound, "mock view not found");
|
||||
return select_view_index;
|
||||
}
|
||||
};
|
||||
|
||||
}}} // namespace
|
||||
4
tests/automation/automation_tests.cpp
Normal file
4
tests/automation/automation_tests.cpp
Normal file
@@ -0,0 +1,4 @@
|
||||
// Catch2 provides main() via Catch2::Catch2WithMain. This TU exists so the
|
||||
// executable has at least one source plus a stable name; per-feature TEST_CASEs
|
||||
// live in the test_*.cpp files.
|
||||
#include <catch2/catch_all.hpp>
|
||||
328
tests/automation/test_dispatcher.cpp
Normal file
328
tests/automation/test_dispatcher.cpp
Normal file
@@ -0,0 +1,328 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include "slic3r/GUI/Automation/JsonRpcDispatcher.hpp"
|
||||
#include "MockUiBackend.hpp"
|
||||
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
using nlohmann::json;
|
||||
|
||||
TEST_CASE("dispatch automation.version", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json req = {{"jsonrpc","2.0"},{"id",1},{"method","automation.version"}};
|
||||
const json resp = d.dispatch(req);
|
||||
CHECK(resp.at("jsonrpc") == "2.0");
|
||||
CHECK(resp.at("id") == 1);
|
||||
CHECK(resp.at("result").at("version") == kAutomationVersion);
|
||||
CHECK(resp.at("result").at("protocol") == "2.0");
|
||||
CHECK(resp.at("result").at("capabilities").is_array());
|
||||
}
|
||||
|
||||
TEST_CASE("unknown method -> -32601", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json req = {{"jsonrpc","2.0"},{"id",7},{"method","does.not.exist"}};
|
||||
const json resp = d.dispatch(req);
|
||||
CHECK(resp.at("id") == 7);
|
||||
CHECK(resp.at("error").at("code") == kMethodNotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON body -> parse error", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const std::string resp = d.handle_request("{not json");
|
||||
const json j = json::parse(resp);
|
||||
CHECK(j.at("error").at("code") == kParseError);
|
||||
CHECK(j.at("id").is_null());
|
||||
}
|
||||
|
||||
TEST_CASE("missing method field -> invalid request", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json req = {{"jsonrpc","2.0"},{"id",2}};
|
||||
const json resp = d.dispatch(req);
|
||||
CHECK(resp.at("error").at("code") == kInvalidRequest);
|
||||
}
|
||||
|
||||
namespace {
|
||||
UiNode dispatcher_tree() {
|
||||
UiNode root; root.klass = "MainFrame"; root.path = "MainFrame";
|
||||
UiNode b; b.id = "btn_slice"; b.klass = "Button"; b.label = "Slice plate";
|
||||
b.path = "MainFrame/Button[0]"; b.rect = {10,20,100,30};
|
||||
UiNode e; e.id = "btn_export"; e.klass = "Button"; e.label = "Export";
|
||||
e.path = "MainFrame/Button[1]"; e.enabled = false;
|
||||
root.children = {b, e};
|
||||
return root;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("tree.dump returns the serialized tree", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","tree.dump"}});
|
||||
const json& result = resp.at("result");
|
||||
CHECK(result.at("class") == "MainFrame");
|
||||
CHECK(result.at("children").size() == 2);
|
||||
CHECK(mock.refresh_count == 1); // refreshed before reading
|
||||
}
|
||||
|
||||
TEST_CASE("tree.find returns matching nodes", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","tree.find"},
|
||||
{"params",{{"class","Button"}}}});
|
||||
CHECK(resp.at("result").size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("widget.get returns a single node by id", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","widget.get"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}}}}});
|
||||
CHECK(resp.at("result").at("id") == "btn_slice");
|
||||
}
|
||||
|
||||
TEST_CASE("widget.get not found -> 1001", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","widget.get"},
|
||||
{"params",{{"target",{{"id","nope"}}}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrNotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("input.click resolves target and clicks it", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","input.click"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
REQUIRE(mock.clicked_ids.size() == 1);
|
||||
CHECK(mock.clicked_ids[0] == "btn_slice");
|
||||
CHECK(mock.click_buttons[0] == MouseButton::Left);
|
||||
}
|
||||
|
||||
TEST_CASE("input.click on disabled widget -> 1002", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","input.click"},
|
||||
{"params",{{"target",{{"id","btn_export"}}}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrNotActionable);
|
||||
CHECK(mock.clicked_ids.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("input.type with target clicks to focus then types", "[automation][rpc]") {
|
||||
MockUiBackend mock; mock.tree = dispatcher_tree();
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","input.type"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}},{"text","hello"}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
CHECK(mock.clicked_ids.size() == 1); // focused first
|
||||
REQUIRE(mock.typed_text.size() == 1);
|
||||
CHECK(mock.typed_text[0] == "hello");
|
||||
}
|
||||
|
||||
TEST_CASE("input.key parses 'ctrl+s' string form", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","input.key"},
|
||||
{"params",{{"keys","ctrl+s"}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
REQUIRE(mock.sent_keys.size() == 1);
|
||||
REQUIRE(mock.sent_keys[0].size() == 1);
|
||||
CHECK(mock.sent_keys[0][0].key == "s");
|
||||
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
|
||||
CHECK(mock.sent_keys[0][0].modifiers[0] == KeyModifier::Ctrl);
|
||||
}
|
||||
|
||||
TEST_CASE("input.key parses array form [\"ctrl\",\"s\"]", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","input.key"},
|
||||
{"params",{{"keys", json::array({"ctrl","s"})}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
|
||||
CHECK(mock.sent_keys[0][0].key == "s");
|
||||
}
|
||||
|
||||
TEST_CASE("app.state returns serialized state", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.state.active_tab = "prepare"; mock.state.project_loaded = true;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","app.state"}});
|
||||
CHECK(resp.at("result").at("active_tab") == "prepare");
|
||||
CHECK(resp.at("result").at("project_loaded") == true);
|
||||
}
|
||||
|
||||
TEST_CASE("screenshot.window returns base64 + dims", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","screenshot.window"}});
|
||||
CHECK(mock.screenshot_window_count == 1);
|
||||
CHECK(resp.at("result").at("width") == 4);
|
||||
CHECK(resp.at("result").at("png_base64").is_string());
|
||||
CHECK_FALSE(resp.at("result").at("png_base64").get<std::string>().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("sync.wait_for succeeds once the condition holds", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
// First 2 polls: btn disabled. 3rd poll: enabled.
|
||||
mock.tree_provider = [](int call) {
|
||||
UiNode root; root.klass = "MainFrame"; root.path = "MainFrame";
|
||||
UiNode b; b.id = "btn_slice"; b.klass = "Button"; b.path = "MainFrame/Button[0]";
|
||||
b.visible = true; b.enabled = (call >= 2);
|
||||
root.children = {b};
|
||||
return root;
|
||||
};
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","sync.wait_for"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}},{"state","enabled"},
|
||||
{"timeout_ms",2000},{"poll_ms",1}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
CHECK(mock.dump_count >= 3);
|
||||
}
|
||||
|
||||
TEST_CASE("sync.wait_for times out -> 1003", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.tree_provider = [](int) {
|
||||
UiNode root; root.klass = "MainFrame"; root.path = "MainFrame";
|
||||
UiNode b; b.id = "btn_slice"; b.visible = true; b.enabled = false;
|
||||
b.path = "MainFrame/Button[0]";
|
||||
root.children = {b};
|
||||
return root;
|
||||
};
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","sync.wait_for"},
|
||||
{"params",{{"target",{{"id","btn_slice"}}},{"state","enabled"},
|
||||
{"timeout_ms",30},{"poll_ms",5}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrWaitTimeout);
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with an array of paths routes to backend", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.open_return_count = 3;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","file.open"},
|
||||
{"params",{{"paths", json::array({"C:/abs/a.stl","C:/abs/b.stl"})}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
CHECK(resp.at("result").at("loaded") == 3);
|
||||
REQUIRE(mock.opened_paths.size() == 1);
|
||||
REQUIRE(mock.opened_paths[0].size() == 2);
|
||||
CHECK(mock.opened_paths[0][0] == "C:/abs/a.stl");
|
||||
CHECK(mock.opened_paths[0][1] == "C:/abs/b.stl");
|
||||
}
|
||||
|
||||
TEST_CASE("file.open accepts a bare string path", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.open_return_count = 1;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","file.open"},
|
||||
{"params",{{"paths","C:/abs/a.stl"}}}});
|
||||
CHECK(resp.at("result").at("loaded") == 1);
|
||||
REQUIRE(mock.opened_paths.size() == 1);
|
||||
REQUIRE(mock.opened_paths[0].size() == 1);
|
||||
CHECK(mock.opened_paths[0][0] == "C:/abs/a.stl");
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with missing paths -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","file.open"},
|
||||
{"params", json::object()}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.opened_paths.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with empty paths array -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","file.open"},
|
||||
{"params",{{"paths", json::array()}}}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.opened_paths.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("file.open with a non-string entry -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","file.open"},
|
||||
{"params",{{"paths", json::array({"C:/a.stl", 42})}}}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.opened_paths.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("file.open backend load failure -> 1007", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.open_should_fail = true;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",6},{"method","file.open"},
|
||||
{"params",{{"paths","C:/abs/a.stl"}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrLoadFailed);
|
||||
}
|
||||
|
||||
TEST_CASE("automation.version capabilities include file.open", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","automation.version"}});
|
||||
const auto& caps = resp.at("result").at("capabilities");
|
||||
bool found = false;
|
||||
for (const auto& c : caps) if (c == "file.open") found = true;
|
||||
CHECK(found);
|
||||
}
|
||||
|
||||
TEST_CASE("view.select routes the view name to the backend", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.select_view_index = 1;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","view.select"},
|
||||
{"params",{{"view","prepare"}}}});
|
||||
CHECK(resp.at("result").at("ok") == true);
|
||||
CHECK(resp.at("result").at("view") == "prepare");
|
||||
CHECK(resp.at("result").at("index") == 1);
|
||||
REQUIRE(mock.selected_views.size() == 1);
|
||||
CHECK(mock.selected_views[0] == "prepare");
|
||||
}
|
||||
|
||||
TEST_CASE("view.select with missing view -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","view.select"},
|
||||
{"params", json::object()}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.selected_views.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("view.select with non-string view -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","view.select"},
|
||||
{"params",{{"view", 42}}}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.selected_views.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("view.select with empty view -> invalid params", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","view.select"},
|
||||
{"params",{{"view",""}}}});
|
||||
CHECK(resp.at("error").at("code") == kInvalidParams);
|
||||
CHECK(mock.selected_views.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("view.select unavailable view -> not found (1001)", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
mock.select_view_should_fail = true;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","view.select"},
|
||||
{"params",{{"view","calibration"}}}});
|
||||
CHECK(resp.at("error").at("code") == kErrNotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("automation.version capabilities include view.select", "[automation][rpc]") {
|
||||
MockUiBackend mock;
|
||||
JsonRpcDispatcher d(mock);
|
||||
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",6},{"method","automation.version"}});
|
||||
const auto& caps = resp.at("result").at("capabilities");
|
||||
bool found = false;
|
||||
for (const auto& c : caps) if (c == "view.select") found = true;
|
||||
CHECK(found);
|
||||
}
|
||||
123
tests/automation/test_locator.cpp
Normal file
123
tests/automation/test_locator.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include "slic3r/GUI/Automation/Locator.hpp"
|
||||
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
|
||||
namespace {
|
||||
UiNode make_tree() {
|
||||
UiNode root;
|
||||
root.klass = "MainFrame";
|
||||
root.path = "MainFrame";
|
||||
|
||||
UiNode panel;
|
||||
panel.klass = "Panel";
|
||||
panel.path = "MainFrame/Panel[0]";
|
||||
|
||||
UiNode slice;
|
||||
slice.id = "btn_slice";
|
||||
slice.klass = "Button";
|
||||
slice.label = "Slice plate";
|
||||
slice.path = "MainFrame/Panel[0]/Button[0]";
|
||||
|
||||
UiNode export_btn;
|
||||
export_btn.id = "btn_export";
|
||||
export_btn.klass = "Button";
|
||||
export_btn.label = "Export";
|
||||
export_btn.path = "MainFrame/Panel[0]/Button[1]";
|
||||
|
||||
UiNode dup; // duplicate label, used for ambiguity tests
|
||||
dup.klass = "Button";
|
||||
dup.label = "Export";
|
||||
dup.path = "MainFrame/Panel[0]/Button[2]";
|
||||
|
||||
panel.children = {slice, export_btn, dup};
|
||||
root.children = {panel};
|
||||
return root;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("flatten yields parents before children", "[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
const auto all = flatten(tree);
|
||||
REQUIRE(all.size() == 5);
|
||||
CHECK(all.front()->klass == "MainFrame");
|
||||
}
|
||||
|
||||
TEST_CASE("find_matches by exact id returns one", "[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
Target t; t.id = "btn_slice";
|
||||
const auto m = find_matches(tree, t);
|
||||
REQUIRE(m.size() == 1);
|
||||
CHECK(m[0]->label == "Slice plate");
|
||||
}
|
||||
|
||||
TEST_CASE("find_matches by exact path returns one", "[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
Target t; t.path = "MainFrame/Panel[0]/Button[1]";
|
||||
const auto m = find_matches(tree, t);
|
||||
REQUIRE(m.size() == 1);
|
||||
CHECK(m[0]->id == "btn_export");
|
||||
}
|
||||
|
||||
TEST_CASE("find_matches by predicate (label) can be ambiguous",
|
||||
"[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
Target t; t.label = "Export";
|
||||
const auto m = find_matches(tree, t);
|
||||
CHECK(m.size() == 2); // btn_export + the duplicate
|
||||
}
|
||||
|
||||
TEST_CASE("find_matches predicate combines fields (AND)",
|
||||
"[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
Target t; t.label = "Export"; t.klass = "Button"; t.id = std::nullopt;
|
||||
// id/path absent -> predicate mode. Both fields must match.
|
||||
t.id = std::nullopt;
|
||||
const auto m = find_matches(tree, t);
|
||||
CHECK(m.size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("find_matches by name matches id OR label", "[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
Target byId; byId.name = "btn_slice";
|
||||
CHECK(find_matches(tree, byId).size() == 1);
|
||||
Target byLabel; byLabel.name = "Slice plate";
|
||||
CHECK(find_matches(tree, byLabel).size() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("find_matches not found returns empty", "[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
Target t; t.id = "nope";
|
||||
CHECK(find_matches(tree, t).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_unique success / not-found / ambiguous",
|
||||
"[automation][locator]") {
|
||||
const auto tree = make_tree();
|
||||
int count = -1;
|
||||
|
||||
Target ok; ok.id = "btn_slice";
|
||||
CHECK(resolve_unique(tree, ok, count) != nullptr);
|
||||
CHECK(count == 1);
|
||||
|
||||
Target missing; missing.id = "nope";
|
||||
CHECK(resolve_unique(tree, missing, count) == nullptr);
|
||||
CHECK(count == 0);
|
||||
|
||||
Target ambiguous; ambiguous.label = "Export";
|
||||
CHECK(resolve_unique(tree, ambiguous, count) == nullptr);
|
||||
CHECK(count == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("evaluate_state covers exists/visible/enabled/value",
|
||||
"[automation][locator]") {
|
||||
UiNode n; n.visible = true; n.enabled = false;
|
||||
n.has_value = true; n.value = "PLA";
|
||||
|
||||
CHECK(evaluate_state(&n, WaitState::Exists, std::nullopt));
|
||||
CHECK(evaluate_state(&n, WaitState::Visible, std::nullopt));
|
||||
CHECK_FALSE(evaluate_state(&n, WaitState::Enabled, std::nullopt)); // disabled
|
||||
CHECK(evaluate_state(&n, WaitState::Value, std::string("PLA")));
|
||||
CHECK_FALSE(evaluate_state(&n, WaitState::Value, std::string("ABS")));
|
||||
CHECK_FALSE(evaluate_state(nullptr, WaitState::Exists, std::nullopt));
|
||||
}
|
||||
80
tests/automation/test_serializer.cpp
Normal file
80
tests/automation/test_serializer.cpp
Normal file
@@ -0,0 +1,80 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include "slic3r/GUI/Automation/WidgetSerializer.hpp"
|
||||
|
||||
using namespace Slic3r::GUI::Automation;
|
||||
|
||||
TEST_CASE("node_to_json emits the unified node shape", "[automation][serializer]") {
|
||||
UiNode n;
|
||||
n.backend = BackendKind::Wx;
|
||||
n.id = "btn_slice";
|
||||
n.path = "MainFrame/Panel[2]/Button[0]";
|
||||
n.klass = "Button";
|
||||
n.label = "Slice plate";
|
||||
n.rect = {100, 200, 120, 32};
|
||||
n.enabled = true;
|
||||
n.visible = true;
|
||||
|
||||
const nlohmann::json j = node_to_json(n, /*include_children*/ false);
|
||||
|
||||
CHECK(j.at("backend") == "wx");
|
||||
CHECK(j.at("id") == "btn_slice");
|
||||
CHECK(j.at("path") == "MainFrame/Panel[2]/Button[0]");
|
||||
CHECK(j.at("class") == "Button");
|
||||
CHECK(j.at("label") == "Slice plate");
|
||||
CHECK(j.at("rect").at("x") == 100);
|
||||
CHECK(j.at("rect").at("w") == 120);
|
||||
CHECK(j.at("enabled") == true);
|
||||
CHECK(j.at("visible") == true);
|
||||
// `handle` must never leak into JSON.
|
||||
CHECK_FALSE(j.contains("handle"));
|
||||
// No value set -> no "value" key.
|
||||
CHECK_FALSE(j.contains("value"));
|
||||
}
|
||||
|
||||
TEST_CASE("node_to_json includes children only for wx when requested",
|
||||
"[automation][serializer]") {
|
||||
UiNode parent;
|
||||
parent.backend = BackendKind::Wx;
|
||||
parent.klass = "Panel";
|
||||
UiNode child;
|
||||
child.backend = BackendKind::Wx;
|
||||
child.klass = "Button";
|
||||
child.label = "OK";
|
||||
parent.children.push_back(child);
|
||||
|
||||
const auto with = node_to_json(parent, true);
|
||||
const auto without = node_to_json(parent, false);
|
||||
|
||||
REQUIRE(with.contains("children"));
|
||||
CHECK(with.at("children").size() == 1);
|
||||
CHECK(with.at("children")[0].at("label") == "OK");
|
||||
CHECK_FALSE(without.contains("children"));
|
||||
}
|
||||
|
||||
TEST_CASE("node_to_json emits value and imgui backend tag",
|
||||
"[automation][serializer]") {
|
||||
UiNode n;
|
||||
n.backend = BackendKind::ImGui;
|
||||
n.klass = "combo";
|
||||
n.has_value = true;
|
||||
n.value = "PLA";
|
||||
const auto j = node_to_json(n, /*include_children*/ true);
|
||||
CHECK(j.at("backend") == "imgui");
|
||||
CHECK(j.at("value") == "PLA");
|
||||
CHECK_FALSE(j.contains("children")); // imgui items are flat
|
||||
}
|
||||
|
||||
TEST_CASE("app_state_to_json shape", "[automation][serializer]") {
|
||||
AppState s;
|
||||
s.active_tab = "preview";
|
||||
s.project_loaded = true;
|
||||
s.slicing = true;
|
||||
s.slice_progress = 42;
|
||||
s.foreground = true;
|
||||
s.modal_dialog = std::string("Save changes?");
|
||||
const auto j = app_state_to_json(s);
|
||||
CHECK(j.at("active_tab") == "preview");
|
||||
CHECK(j.at("project_loaded") == true);
|
||||
CHECK(j.at("slice_progress") == 42);
|
||||
CHECK(j.at("modal_dialog") == "Save changes?");
|
||||
}
|
||||
78
tools/automation/example_slice.py
Normal file
78
tools/automation/example_slice.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""End-to-end smoke test: launch OrcaSlicer with the automation server, load a
|
||||
model, slice it, wait for completion, and save a window PNG.
|
||||
|
||||
Run:
|
||||
python example_slice.py --orca /path/to/OrcaSlicer --model /path/to/cube.stl
|
||||
|
||||
On Linux CI, wrap with a virtual display, e.g.:
|
||||
xvfb-run -a python example_slice.py --orca ./OrcaSlicer --model cube.stl
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from orca_automation import OrcaClient, OrcaError
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--orca", required=True, help="path to the OrcaSlicer executable")
|
||||
ap.add_argument("--model", required=True, help="path to an STL/3MF to load")
|
||||
ap.add_argument("--port", type=int, default=13619)
|
||||
args = ap.parse_args()
|
||||
|
||||
proc = subprocess.Popen([
|
||||
args.orca,
|
||||
"--automation-server",
|
||||
f"--automation-server-port={args.port}",
|
||||
])
|
||||
try:
|
||||
orca = OrcaClient(port=args.port)
|
||||
|
||||
# Wait for the server to come up.
|
||||
for _ in range(60):
|
||||
try:
|
||||
print("connected:", orca.version())
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("ERROR: automation server did not start", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Switch to the Prepare (3D editor) view first, then load the model into the
|
||||
# already-running instance. file.open is synchronous, so project_loaded is
|
||||
# already true on return; the wait below is a belt-and-suspenders guard.
|
||||
orca.select_view("prepare")
|
||||
orca.open([args.model])
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if orca.app_state().get("project_loaded"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
# Click Slice and wait for the Export button to become enabled
|
||||
# (slicing complete) — wait_for replaces fragile fixed sleeps.
|
||||
orca.click({"id": "btn_slice"})
|
||||
orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=180000,
|
||||
poll_ms=500)
|
||||
|
||||
# The window screenshot is captured from the on-screen composited
|
||||
# framebuffer, so it already includes the 3D viewport (model in the
|
||||
# editor, or toolpaths in Preview after slicing).
|
||||
with open("window.png", "wb") as f:
|
||||
f.write(orca.screenshot())
|
||||
print("wrote window.png")
|
||||
return 0
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
120
tools/automation/orca_automation.py
Normal file
120
tools/automation/orca_automation.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Reference client for the OrcaSlicer UI automation JSON-RPC server.
|
||||
|
||||
Usage:
|
||||
from orca_automation import OrcaClient
|
||||
orca = OrcaClient(port=13619)
|
||||
print(orca.version())
|
||||
orca.click({"id": "btn_slice"})
|
||||
orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=120000)
|
||||
png = orca.screenshot()
|
||||
open("window.png", "wb").write(png)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class OrcaError(RuntimeError):
|
||||
def __init__(self, code: int, message: str):
|
||||
super().__init__(f"[{code}] {message}")
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class OrcaClient:
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 13619, timeout: float = 30.0):
|
||||
self._url = f"http://{host}:{port}/jsonrpc"
|
||||
self._timeout = timeout
|
||||
self._id = 0
|
||||
|
||||
def _call(self, method: str, params: Optional[dict] = None) -> Any:
|
||||
self._id += 1
|
||||
payload = {"jsonrpc": "2.0", "id": self._id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self._url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
if "error" in body:
|
||||
err = body["error"]
|
||||
raise OrcaError(err.get("code", -1), err.get("message", "unknown error"))
|
||||
return body.get("result")
|
||||
|
||||
# --- protocol methods ---
|
||||
def version(self) -> dict:
|
||||
return self._call("automation.version")
|
||||
|
||||
def dump_tree(self, root: Optional[str] = None, max_depth: Optional[int] = None,
|
||||
visible_only: bool = False, include_imgui: bool = True) -> dict:
|
||||
params: dict = {"visible_only": visible_only, "include_imgui": include_imgui}
|
||||
if root is not None:
|
||||
params["root"] = root
|
||||
if max_depth is not None:
|
||||
params["max_depth"] = max_depth
|
||||
return self._call("tree.dump", params)
|
||||
|
||||
def find(self, **predicate) -> list:
|
||||
# predicate keys: name, class, label, value, backend
|
||||
return self._call("tree.find", predicate)
|
||||
|
||||
def get(self, target: dict) -> dict:
|
||||
return self._call("widget.get", {"target": target})
|
||||
|
||||
def click(self, target: dict, button: str = "left",
|
||||
double: bool = False, modifiers: Optional[list] = None) -> dict:
|
||||
params = {"target": target, "button": button, "double": double}
|
||||
if modifiers:
|
||||
params["modifiers"] = modifiers
|
||||
return self._call("input.click", params)
|
||||
|
||||
def type(self, text: str, target: Optional[dict] = None) -> dict:
|
||||
params: dict = {"text": text}
|
||||
if target is not None:
|
||||
params["target"] = target
|
||||
return self._call("input.type", params)
|
||||
|
||||
def key(self, keys) -> dict:
|
||||
# keys: "ctrl+s" or ["ctrl", "s"]
|
||||
return self._call("input.key", {"keys": keys})
|
||||
|
||||
def open(self, paths) -> dict:
|
||||
"""Load one or more files into the running instance at runtime.
|
||||
|
||||
`paths` is a single absolute path string or a list of them. Paths are read
|
||||
from the host filesystem by the server (localhost-only). Returns
|
||||
{"ok": True, "loaded": <count>}. Raises OrcaError 1007 on load failure."""
|
||||
if isinstance(paths, str):
|
||||
paths = [paths]
|
||||
return self._call("file.open", {"paths": list(paths)})
|
||||
|
||||
def select_view(self, view) -> dict:
|
||||
"""Switch the main window to a top-level view/tab by name.
|
||||
|
||||
`view` is one of: "home", "prepare", "preview", "device",
|
||||
"multi_device", "project", "calibration". Returns
|
||||
{"ok": True, "view": <name>, "index": <int>}. Raises OrcaError 1001 if the
|
||||
view name is unknown or not available in the current layout."""
|
||||
return self._call("view.select", {"view": view})
|
||||
|
||||
def wait_for(self, target: dict, state: str = "visible",
|
||||
value: Optional[str] = None, timeout_ms: int = 5000,
|
||||
poll_ms: int = 100) -> dict:
|
||||
params = {"target": target, "state": state,
|
||||
"timeout_ms": timeout_ms, "poll_ms": poll_ms}
|
||||
if value is not None:
|
||||
params["value"] = value
|
||||
return self._call("sync.wait_for", params)
|
||||
|
||||
def app_state(self) -> dict:
|
||||
return self._call("app.state")
|
||||
|
||||
def screenshot(self, target: Optional[dict] = None) -> bytes:
|
||||
"""Capture a window as a PNG, exactly as composited on screen (includes the
|
||||
GL 3D viewport and ImGui overlays). Defaults to the main frame."""
|
||||
params = {"target": target} if target is not None else None
|
||||
result = self._call("screenshot.window", params)
|
||||
return base64.b64decode(result["png_base64"])
|
||||
Reference in New Issue
Block a user