Skip to content

The ring shows ZBrush's state: lit toggles, current slider values, dimmed slots #188

Open

Description

@combwizard

Today the ring sends picks and reads nothing back. As the ring opens, the app would ask the plugin for a small snapshot and draw it: a Toggle slot lit when its switch is on, a Set slot showing the slider's current value, a slot dimmed when ZBrush would ignore it right now (everything under Tool:Masking with no mesh in Edit mode, which today fails with "no such interface item" after the pick), and the active tool's name in the hub.

The plugin has the calls for all of it (get, get_status, exists, get_active_tool_path). The Python API has no event or timer hook, so the snapshot is asked for when the ring opens, with a short timeout, and the last one is used when ZBrush is busy. ZBrush 2022 to 2025 can only answer when Apply runs, so there the ring would show the state as of the last pick.

A subtools fan and menus that change with the tool or with Transpose would build on the same channel.

Activity

  1. added
    bridgeHow ZOrbit talks to ZBrush, and the ZBrush plugins
    ringThe ring and fans: look, items, behaviour
    ideaAn idea that is not planned yet. Add a thumbs-up if you want it
    on Nov 6, 2026
  2. combwizard commented on Nov 6, 2026

    @combwizard
    OwnerAuthor

    Measured in ZBrush 2027.0 on 6 Nov 2026, with a read-only probe loaded through ZScript > Python Scripting > Load:

    • Reads are cheap: about 900 calls (get, get_status, get_title, get_hotkey, get_min, get_max and the rest over 48 paths) took 11 ms in all. A snapshot of a ring's items on open costs nothing noticeable.
    • The current brush is readable: get_title("Brush:Current Brush") gives the brush's .zbp path, get_title("Brush:ItemInfo") gives Standard., and get("Brush:Standard") is 1.0 while it is selected. The same works for Tool:ItemInfo and Material:ItemInfo.
    • get_status and is_enabled tell a greyed item apart: with a 2.5D tool on the canvas, Transform:Edit exists but is disabled, and everything under Tool:Masking and Tool:Geometry does not exist, as the docs say.
    • get_hotkey returns the full combination (CTRL+SHIFT+z, CTRL+ALT+c), not the one-modifier form its docstring describes.
    • A Python thread started by the plugin does not run while ZBrush is idle: it wrote one tick and never woke again. So the snapshot has to be taken on ZBrush's main thread, from the listener's window message, as the bridge already does for picks. There is no event or timer hook in the API, and the zbrush.auto_zcmds name found in the exe is not an importable module.
  3. combwizard commented on Nov 7, 2026

    @combwizard
    OwnerAuthor

    Design

    Worked out 7 Nov 2026 from the measurements above. Decisions taken from the open questions at the end: the switch defaults to on; a dimmed slot can still be picked; the tool name on the hub waits for #193; a pip on the current brush's slot is a follow-up.

    1. Protocol

    Two new window messages:

    • WM_APP_STATE = WM_APP+2, app to the plugin's listener. wparam is the query id, lparam the app's control window.
    • WM_APP_STATE_READY = WM_APP+19, plugin to the app's control window. wparam is the snapshot id. +19 is the free number in radiald's 10 to 23 range.

    The plugin posts the reply only when GetClassNameW(lparam) is ZOrbitControl, so it never posts to a foreign window.

    The app writes %LOCALAPPDATA%\ZOrbit\zstate_query.json atomically (tmp file, then os.replace, as request.json is written):

    {"v": 1, "id": 42, "time": 1762430000.1,
     "paths": [{"p": "Transform:Activate Symmetry", "k": "toggle"},
               {"p": "Draw:Draw Size", "k": "set"}, {"p": "Tool:Masking:Clear", "k": "press"}]}

    The plugin writes %LOCALAPPDATA%\ZOrbit\zstate.json atomically:

    {"v": 1, "id": 42, "pick": 17, "pid": 9120, "session": "429428",
     "time": 1762430000.13, "ms": 1.9, "truncated": false,
     "extras": {"tool": "SimpleBrush", "brush": "Standard", "brush_file": "C:/.../Standard.zbp",
                "material": "MatCap Red Wax", "edit": true, "edit_enabled": true},
     "items": {"Transform:Activate Symmetry": {"s": "ok", "v": 1.0},
               "Draw:Draw Size": {"s": "ok", "v": 64.0, "min": 1.0, "max": 1000.0},
               "Tool:Masking:Clear": {"s": "missing"}}}
    • s is ok, disabled or missing, from exists and is_enabled; error when a read raised, drawn neutral.
    • v only for set and toggle; min and max only for set.
    • Keys are the exact query strings, so the app never normalises paths.
    • Tool and brush names drop ZBrush's trailing dot (get_title gives SimpleBrush.).
    • pick is the id of the last request the plugin applied before this snapshot.

    The app accepts a snapshot when its id is the newest asked (or it is the refresh after a pick), its pid is the ZBrush the query went to, and v == 1. The plugin answers the id it finds in the file, not the wparam, so a newer query that replaced the file is the one answered.

    _publish_listener adds "state": 1 to listener.json. The app asks only a listener that publishes it, so an old plugin is never asked.

    No waiting, one question in flight. ask() runs after ring.show(), so the ring is on screen before the query is written. While a question has no answer, a new ring only rewrites the query file and posts nothing; if ZBrush is busy, the one queued message answers the newest query when it is free. After 2 s without an answer the question counts as lost: the next open posts again and radiald.log gets one line per session. The ring never waits.

    Mid-stroke. The handler does not check primary_down(): reads change nothing, so they are not held back the way picks are. (Risk 1 below.)

    Cache (zstate.Cache): entries merged across replies, one per path with its time. An entry is drawn only when it is from the same pid and session as the ZBrush in front, under 30 s old, and not older than the last pick: send() calls zstate.picked(seq), and a snapshot with pick >= seq makes the cache drawable again. After apply_request the plugin answers its last query again, unasked, so the cache is current before the next open. A restarted ZBrush changes pid and session and drops the cache.

    2. What is queried

    zstate.paths_for(menu, resolve) collects, in order: the menu's own press, set and toggle items; the items of fan submenus down to depth 3 (fans open inside the same ring and never ask on their own); the items of ring submenus one level down (they ask again when they open, this only warms their first frame). Paths are deduplicated, merging kinds. steps, asset, capture, file and tag items are not queried, and a steps item is never dimmed.

    Extras on every snapshot: Tool:ItemInfo, Brush:ItemInfo, get_title("Brush:Current Brush"), Material:ItemInfo, and Transform:Edit with its enabled state.

    Cost cap: at most 128 paths; the plugin also stops after 20 ms and sets truncated. At about 4 reads per path and 10 us a read, 128 paths take about 5 ms.

    3. Drawing

    Overlays, not new slot pictures. ring.py keeps each slot's picture between opens; the state does not touch those pictures or their cache key. The ring takes marks=callable, where marks(menu) gives a zstate.Mark(lit, value_text, dim) or None per item, and Ring.refresh_marks() re-runs it for the ring and its open fans. When a slot's mark changes, the ring copies its idle and hover pictures with the overlay drawn in (under 0.1 ms a slot, kept per slot key and mark), clears that slot's blend cache and redraws on the next tick. Geometry, hit testing and animation are unchanged.

    Toggle. A pip (0.13 of the slot width) at the upper right, the badge corner mirrored upwards; toggles are never submenus, so it never meets the submenu badge. On: filled in the slot's hover colour (its glow_color, else the accent) with a rim in the background colour, and the slot gets an outline in that colour, max(2, border_width) wide. Off: a hollow pip in the text colour at 35 %. No snapshot: no pip. Unknown, off and on all look different.

    Set. A value chip inside the disc, centred at 0.55 radius below the centre, drawn like the label pill (text colour, 9 px by scale, bold). The label under the slot does not change. A Set slot whose target equals the current value (within half the last digit shown) gets the filled pip too. When one menu has several Set slots for one slider (a row of presets), no chip is shown and only the matching slot lights.

    zstate.format_value(v, lo, hi, target): span 100 or more gives no decimals, 10 or more one, 1 or more two, else three; at least as many decimals as the slot's own value; trailing zeros removed; 10000 or more written 12.5k; at most 5 characters; with no range, at most 2 decimals.

    Dimmed. Missing and disabled look the same: slot and label at 40 % opacity, 70 % under the cursor, as an alpha factor to _draw_disc and _draw_label. ZBrush reports items as missing in some contexts (Tool:Masking with a 2.5D tool), so missing does not mean a typo; radiald.log notes each missing path once per session. A dimmed slot can still be picked: the snapshot can be 30 s old and a wrong snapshot must never break a menu. Picking an item that really is gone gives the existing message.

    Empty, auto, tag and asset slots never get a mark. Tag colours, looks and shapes are untouched. The overlay is in both the idle and the hover picture, so hover, scaling and fans behave as now.

    4. Stale or missing state

    With no plugin, an old plugin, ZBrush 2022 to 2025, the setting off, or no listener for the ZBrush in front, nothing is asked and the ring is exactly as now. ZBrush busy: the cached marks if drawable, else a neutral ring, updated in place if the answer comes while the ring is open. Right after a pick: neutral until the refresh arrives, usually before the next open. Never a spinner or a stale marker.

    5. ZBrush 2022 to 2025

    Left out of the first version. The plugin runs only on Apply, the moment the state changes, so changes made in ZBrush's own interface would never be seen and a toggle would often be wrong, which is worse than nothing. It would also need a second, line-based format in the Windows code page with 255-character lines. The v field and the Snapshot model leave room for a "source": "zscript" writer later.

    6. Studio

    A new switch "bridge": {"show_state": true}, default on, per profile: Show ZBrush's state on the ring under Settings > ZBrush bridge (Full mode). Off means nothing is asked. The preview stays neutral: Studio passes no marks. Ring.preview(..., marks=None) serves tests and tools/ring_shots.py, which gets a new state.png.

    Docs: bridge.md (the channel, the version note, what Status shows), menus.md (Press, Set, Toggle), appearance.md (the picture), studio.md (the setting), files.md (the two files), troubleshooting.md (dimmed slots, slots that show no state), CHANGELOG.md, the README feature list.

    7. Tests

    • New tests/test_zstate.py: the parser (valid; wrong v, id or pid; bad JSON; NaN, bools and strings as values; keys not asked; a file over 256 KB refused); paths_for (order, dedupe, fan depth, steps left out, the cap); the cache rules (pick, age, session); format_value from a table; the preset rule; Channel with PostMessageW monkeypatched (one in flight, no ask without the capability or with the setting off, a late or foreign reply ignored).
    • tests/test_bridge.py: the stub gains is_enabled, get_min, get_max, get_title, zbrush_info; the snapshot contents and statuses; an answer while primary_down is True; the time budget and truncated; a raising read gives error; a foreign hwnd refused; listener.json has state: 1; the refresh after apply_request.
    • tests/test_ring.py: marks=None gives a picture identical pixel for pixel to today's; pip lit and off; the chip; a dimmed slot's alpha; hover keeps the overlay; the slot-picture cache key unchanged and refresh_marks never calls _slot_sprites.
    • By hand in ZBrush 2026 or 2027: flip Symmetry in ZBrush and reopen the ring; change Draw Size; Masking items dimmed with a 2.5D tool; open the ring during a stroke and during a long Dynamesh (no delay); two instances; the timing line in radiald.log. In ZBrush 2022: the ring unchanged, no zstate*.json, no new log lines.

    8. Phases

    # Work Model Cold review
    1 Plugin: the WM_APP_STATE handler, _snapshot(), zstate.json, the reply, the capability flag, the refresh after a pick, the Status line, tests Opus Yes
    2 zorbit/zstate.py: Query, Snapshot, Cache, Mark, paths_for, marks_for, format_value, Channel, tests Sonnet Yes
    3 ring.py: marks, refresh_marks, the overlays, dimming, preview(marks=), tests Opus Yes
    4 radiald (about 20 lines): _listener_info, the constant, the _ctrl_proc dispatch, ask in open_menu, picked in send; config.py parses show_state Opus Yes
    5 Studio switch, the docs, the CHANGELOG Sonnet Yes
    6 state.png in ring_shots.py, retaken and checked Haiku No

    Phases 1, 2 and 3 can run in parallel against this schema; 4 waits for all three; 5 and 6 follow.

    9. Risks

    1. Reads during a stroke, or no messages pumped during a stroke: measure in phase 1. If either fails, answer once the pen lifts and draw the cache meanwhile.
    2. The answer arrives later than the open animation: measure the round trip. Under about 50 ms the marks land while the slots are still fading in.
    3. Antivirus slowing the two small file writes: the ms field and the app's timing line will show it. WM_COPYDATA was rejected because sending it would block ZBrush on the app.
    4. Toggles that are not plain on and off switches: lit means v != 0; check a few in ZBrush 2027.
  4. combwizard commented on Nov 13, 2026

    @combwizard
    OwnerAuthor

    Checked against the code, 13 Nov 2026

    The design above was written before #210 (ring-perf), which changes how the ring keeps its pictures. Read against that branch, the design stands. These are the points each phase needs to know.

    Order

    Phase 1, plugin

    • WM_APP+2 is free in the plugin: it has WM_APP_PICK = +1 and three timers.
    • _publish_listener writes hwnd, pid, thread and verified; "state": 1 goes beside them.
    • The refresh after a pick has a gap. apply_request can leave items in S.select_pending, which TIMER_SELECT presses up to a second later. A refresh sent straight after apply_request then reports the brush from before the load in extras. Send the refresh once more when that queue empties. Toggles and sliders are not affected.
    • Stub in tests/test_bridge.py has exists, press, set, toggle and get. The rest is to add, as the design lists.

    Phase 3, ring

    • self.sprites is the very list kept in _sprite_cache, shared by every open of that menu and by the warm-up. refresh_marks has to copy the list before it puts a marked picture in it, or the mark stays for the next open. fan.sprites is already a new list for each ring (_fan); self._art holds the cached tuples and is left alone.
    • Marked copies are kept on the ring, or in a small cache of their own by slot key and mark. Not in _slot_cache: its 160 entries are shared with the warm-up's budget.
    • _resized (new in The ring opens faster: pictures kept between opens and drawn ahead of the first #210) checks the picture by identity, so a swapped slot picture needs no clearing there. _blend_cache has no such check and is cleared for that slot, as the design says.
    • Marks stay out of _sprite_key, _slot_key and _static_key. The static layer's key holds each slot's kind and rim colour only, and the toggle outline is drawn in the slot's picture, so a change of state never redraws the static layer.
    • Dimming: _draw_disc and _draw_label both take alpha. The factor goes in at their four call sites in _compose and _draw_fan.
    • Ring.preview now also takes open_path, held and open_age; marks=None joins them. Ring.for_warmup passes no marks: the warm-up draws neutral pictures and the marked copies are made on open.
    • Two more tests: after refresh_marks, the entries of _sprite_cache and _slot_cache are the same objects with the same pixels; and a second open of the menu without marks is identical to today's picture.

    Phase 4, daemon

    • WM_APP+19 is still free (10 to 18 and 20 to 23 are taken).
    • The function that reads listener.json is _listener(pid) and returns only the window. _listener_info replaces it or sits beside it, and gives the state flag from the same read, behind the same pid and exe checks.
    • open_menu works on the expanded menu (self.expander.menu(menu)). paths_for takes that one, with self.expander.resolve.
    • The warm-up needs nothing: _warm_busy is already true while a ring is open or a pick has no answer, so no warm-up picture comes between a question and its answer while the ring shows.
    • BRIDGE_KEYS in config.py is ("mode", "key"). show_state has to join it, or the parser reports it as an unknown setting.

    Phase 5, Studio

    • The card is card('ZBrush bridge', ...) in app.js, and its id set-zbrush-bridge is in FULL_CARDS in mode.js. The check beside it in mode.js, which names the card when the mode or the key is not the default, should count show_state: false too.

    Still to measure in phase 1

    Unchanged: reads during a stroke, and the round trip against the open animation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bridgeHow ZOrbit talks to ZBrush, and the ZBrush pluginsideaAn idea that is not planned yet. Add a thumbs-up if you want itringThe ring and fans: look, items, behaviour

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions