Replaced by one input_files() call (comfy-env >= 0.4.34). What left and why
each was safe to delete:
- The comfy_env_* marker dict: the recipe now travels as input_files'
arguments, journaled by the metadata scan. No private vocabulary in
/object_info.
- get_mesh_files(): a byte-for-byte sibling of comfy-env's parent-side
scanner that had to be kept in sync by hand. input_files() IS that
scanner -- one implementation, every process.
- validate_inputs(cls, **kwargs): the **kwargs form exempted EVERY input
from execution.py's checks, numeric clamps included. comfy-env now
synthesizes the narrow named-arg form covering exactly file_path.
- fingerprint_inputs: synthesized parent-side (mtime over the resolved
path) under the subset gate; the hand-rolled copy was silently dropped
at the isolation boundary anyway.
Under vanilla ComfyUI (no comfy-env) input_files() simply returns the live
list, so the node stays correct un-isolated.
Verified live: a mesh dropped into a new subfolder of input/3d appears in
the dropdown immediately; deleting it removes it and restores the
placeholder; the synthesized validate is (cls, file_path) with no varkw.
The nine U+2248 ALMOST EQUAL TO characters in the remeshing tooltips cannot be
encoded in cp1252, which fails the syntax level on Windows. Spell them "~=".
Field report: target 5000 vertices delivered ~19k. Expert audit ran the
pynanoinstantmeshes binding directly and PROVED the cause: the solver
receives the target correctly, computes the correct quad-lattice scale
(sqrt(A/V), verified to the digit), extracts on target -- and then the
binding always runs Instant Meshes' "pure quad mesh" subdivision pass
(each face -> 4 quads, V' = V+E+F ~= 4V) WITHOUT the GUI's compensating
scale adjustment. Measured 4.03-4.14x across a decade of targets: a
deterministic constant, not hierarchy snapping (the previous tooltip's
diagnosis was wrong), not a dropped parameter.
Fix: pre-divide the requested vertex count by the topologically pinned
factor 4 in the wrapper. Verified post-fix accuracy: 500->550, 1000->1052,
5000->5104, 12000->12134 (~±10%, normal Instant Meshes tolerance). The
dispatcher's faces path (V = F_tri/2) stays correct: output quads ~= V and
triangulation emits 2 tris/quad (confirmed 5104 v -> 10204 tris).
Tooltips updated accordingly (drop the "snaps 2-4x" folklore; ~±10%).
Field regression caught immediately: ArmoredWarrior_00005_.glb was rejected
for pymeshlab with "279 non-manifold edges" -- but the RAW connectivity the
backend receives has ZERO (verified: max edge incidence 2). The old check ran
on position-MERGED topology, where the asset's UV/normal seam splits (18061
verts, 9574 unique positions) manufacture non-manifold edges that don't
exist in the mesh the library processes.
Fix, two parts:
- _edge_stats now returns BOTH views. Manifold questions use RAW (what the
backend consumes); open-mesh questions use MERGED (raw view misreads
duplicated-vertex soup as 100% boundary -- the same asset reads 13763
boundary edges raw vs 9 merged).
- Hard REJECT shrinks to the catastrophic-failure trio (quadriflow segfault,
quadwild hang, cgal UB). pymeshlab / PMP / MMG fail cleanly on their own,
so genuine raw non-manifoldness now WARNS with a repair route instead of
blocking input the library may handle.
Also: instant_meshes target tooltips updated from "~±2x" to the observed
reality -- the solver snaps to its multiresolution hierarchy and can land
2-4x off (field report: 5k requested -> 19k delivered); treat as an
order-of-magnitude knob and read the achieved count from the info output.
Tests: coincident stacked boxes (the armor-plate case) pass clean for
pymeshlab; genuine 3-fan non-manifold warns on pymeshlab and rejects on
quadriflow; soup box still not misread as open; truly open mesh still
rejected for blender_voxel.
Replaces the hidden "0 = off" sentinel precedence (which was inconsistent
across backends: pymeshlab resolved faces>vertices>edge, quadwild the
opposite) with a `target` DynamicCombo nested in each backend option -- pick
ONE driver, see ONE input. Design and conversion math vetted by two
independent expert reviews; conversions that would lie are simply not
offered.
Per-backend target menus (first input under the backend selector):
- pymeshlab: faces|vertices|edge_length (native back-solve, forwarded)
- pmp_uniform: faces(F->V=F/2)|vertices|edge_length
- cgal: faces|vertices (equilateral back-solve e=sqrt(4A/(sqrt3 F)))|edge
- geogram_smooth: vertices(nb_points, near-exact)|faces|edge_length
- geogram_aniso: vertices|faces only (no scalar edge length exists)
- instant_meshes: vertices|faces (labeled ~±2x)
- quadriflow: faces(TRIANGLES; quad budget = F/2)|vertices|edge(Q=A/e^2)
- quadwild: faces|vertices|edge_length|scale_factor (~±50%, labeled)
- gpu_cumesh: faces|vertices only (simplifier hits counts; edge refused --
adaptive simplification leaves no uniform edge length). Default 100k
(was 500k).
- blender_voxel: faces|vertices|edge_length|voxel_size (square-cell
constants; ±2x-on-open/thin caveat in tooltips)
- blender_sharp: octree_depth|edge_length (snapped to power of 2);
counts refused (4x quantization)
- mmg: `sizing` combo: adaptive[hmin,hmax]|uniform_edge_length[hsiz]|
uniform_vertices|uniform_faces (->hsiz); hausd stays outside (always used)
- blender_blocks, pmp_adaptive, faithc: unchanged (counts are emergent /
discrete there)
Defaults normalized (were 5000 quads vs 500000 faces across backends);
0-sentinels removed from the UI (they live on inside hidden backend nodes,
resolved by construction: only the chosen driver's param is forwarded).
Dispatcher additions (hidden backend nodes untouched):
- _CHOICE_COMBOS registry distinguishes choice combos from on/off toggles
(no value-shape sniffing).
- _resolve_target(): equilateral constants for triangle backends, square-
cell for quad/voxel; logs requested -> native for every conversion;
sharp's depth uses the sibling `scale` and reports the achieved cell.
- _preflight() input gating (expert-audited): rejects 0-face/point-cloud,
NaN/Inf vertices, zero-extent bbox; non-manifold edges for the halfedge +
quad-parametrization backends (segfault/hang class); open meshes for
blender_voxel (silent empty output); grid-explosion guards for voxel
sizes and absolute hausd (the hang/OOM class), also applied to CONVERTED
voxel sizes. Non-manifold detection runs on MERGED-VIEW topology (vertex-
position hash) so STL-style duplicated-vertex soup isn't misread as all-
boundary. Warnings (not fatal): open mesh on cumesh/faithc, inconsistent
winding on volumetric backends, degenerate-face fraction, and count-
voiding settings (pymeshlab adaptive, voxel adaptivity, quadriflow
adaptive_scale). Every rejection names an alternative backend or repair
node.
Unit-tested via stubbed comfy_api import of the real module: 10 conversion
checks (constants exact) + 12 gating checks all pass. Old Remesh workflows
need re-picking (user remaking them).
Third TRIMESH output (appended last so existing sliced_mesh/info links keep
their indices). sliced_mesh stays the transform result; mesh is the pure
passthrough.
Both were dead-end previews (UV declared no outputs; Boundaries an explicit
outputs=[]). Each now declares a TRIMESH `mesh` output and returns its input
mesh alongside the UI data, so they can sit mid-graph like every other
preview node. UV's point-cloud early-return passes the mesh through too.
Boundaries' vertex-merge already operates on a copy, so the passthrough is
the untouched original.
Get Mesh From Batch: `index` becomes `selection`, accepting an index ("2",
"-1", clamped) or a Python slice ("1:5", ":3", "::2", "-3:", self-clamping).
Output is now a TRIMESH list (a single index yields a batch of one, which
maps identically downstream). Parser covered by exhaustive standalone tests
(indices, negative, out-of-range, all slice forms, error cases).
Preview Mesh Multi (Batch) deleted (node, widget JS, registration): the
autogrown Preview Mesh Multi subsumes it -- batch-accepting sockets, better
auto grid, client-side dims, wipe layout, full-list passthrough vs its silent
rows*cols truncation. workflows/extract_connected_components.json still
references the deleted node; CI breakage accepted by user, workflow to be
migrated separately.
Replaces the fixed mesh_1..mesh_4 sockets with an io.Autogrow input (same
pattern as core's MergeSplat): a fresh socket appears as you connect each
mesh, up to 16. INPUT_IS_LIST makes every socket accept a single mesh OR a
whole batch; everything is flattened in socket order -- so a batch of 15 plus
one extra mesh previews all 16 together.
Auto grid is now near-square ceil(sqrt(n)) (1x1, 2x1, 3x1, 2x2, 3x2, 3x3,
4x3, 4x4) instead of capping at 2x2; the widget's client-side Cols/Rows still
override. Display capped at 16 viewports with a loud log: each viewport is
its own WebGL context and browsers kill contexts beyond ~16/page. The FULL
flattened list (beyond the cap too) passes through the output.
Breaking for saved workflows that linked mesh_1..mesh_4 (socket names are now
mesh_0..): links need reconnecting. Widget JS unchanged (reads mesh_files).
The three.js preview's generic name made it unclear next to the VTK/Dual/
Multi/Batch previews. Renamed the node id (schema + both mappings), the
widget file (mesh_preview.js -> mesh_preview_three.js, extension name
geometrypack.meshpreview -> geometrypack.meshpreview.three), and the four
test workflows that reference the node type.
Breaking for external saved workflows that used GeomPackPreviewMesh (node
type no longer exists) -- accepted; the pack's own workflows are updated.
Mode is controlled solely by each node's own mode input widget, matching the
Dual and VTK preview policy. Bars keep navigation/layout controls + fullscreen.
- Dual / Multi / VTK preview: the control bar now sits BETWEEN the canvas and
the info panel (canvas on top, controls below), matching the batch node's
nav bar. Border flipped bottom->top to suit the new position.
- Preview Mesh Dual: Mode selector removed from the bar -- the node's own
`mode` input widget is the only mode control (same policy as Preview Mesh
VTK). Bar keeps Layout, per-mesh color/opacity (overlay), and fullscreen.
Preview Mesh Dual (fields overlay):
- The overlay now renders from the TWO per-mesh files (already exported every
run) as two actors in one renderer, replacing the combined-file single
actor. Per-mesh opacity finally works for real (the old path could only
apply the AVERAGE of both opacities to one merged actor).
- New color pickers (1: / 2:) next to the opacity inputs in the bar, overlay
only. Colors + opacities are pure client-side (viewerState-persisted,
re-posted on change); defaults red/blue match the old baked colors.
- Viewer (viewer_dual.html): second overlay actor; layout-aware mapper +
render helpers (mapper1Active/mapper2Active/renderActiveViews) so field
selection, colormaps, and edge toggles now work in overlay too (previously
field application only targeted the side-by-side mappers); settings apply
point size/edges/representation to both overlay actors (color stays
per-mesh). Legacy combined-file overlay path kept for old widgets; the
textured overlay still uses the combined GLB (colors don't apply to
textures).
Preview Mesh (VTK): Mode selector removed from the JS bar -- the node's own
`mode` input is the only control (bar keeps just the fullscreen button).
layout and opacity_1/opacity_2 are gone from the schema; mode stays a real
node input (as directed).
Python: with no layout to branch on, every run exports BOTH view sets -- the
separate pair (side_by_side / slider) and the combined overlay file -- using
the same untouched export helpers (_export_mesh, _export_combined_mesh; the
combined helper just loses its never-used opacity params). ui_data carries
both file sets; opacity keys dropped (Python only ever echoed them);
common_fields_overlay added (the combined export injects mesh_id).
JS: layout and opacities move into viewerState (persisted via the DOM
widget's serialization, like show_edges already was). Every layout switch is
now CLIENT-SIDE -- overlay included, since its file always exists -- and
opacity changes re-post immediately. Only a mode change re-runs (different
export format). No widgets are hidden; mode renders as a normal node input.
Cost: one extra combined export per run. Old workflows whose saved widget
values included layout/opacity load with those values ignored.
It registered geometrypack.meshpreview.vtk.textured for node
GeomPackPreviewMeshVTKWithTexture, which the pack no longer ships (textured
preview is now a `mode` on GeomPackPreviewMeshVTK / ...VTKBatch). So its
beforeRegisterNodeDef never matched -- dead weight that squatted a removed
node. Surfaced by comfy-test's new foreign-node-hook rule; missed in the
earlier squat cleanup because its GeomPack-prefixed name looked like ours.
Nothing imports it; viewer_vtk_textured.html stays (still used by the VTK
single + batch widgets for texture mode). Pack is back to 0 lint errors.
The control bars are now the ONLY visible controls; the node input widgets
they drive are hidden via a new hideWidgets() util (uiComponents.js). The
widgets keep existing -- values still serialize into the workflow and flow to
Python as inputs -- they just stop rendering on the node body.
- Multi / VTK preview / Batch: `mode` widget hidden (bar Mode selector drives
it). Batch keeps its `index` widget visible (useful for typing an exact
index).
- Dual: `layout`, `mode`, `opacity_1`, `opacity_2` all hidden. Opacity moves
into the bar (shown only for the Overlay layout) and is applied
CLIENT-SIDE: changing it updates the hidden widgets (persistence + next
run) and re-posts to the viewer immediately -- no re-run. render() now
prefers widget values over the last message's, which also fixes the
`|| 1.0` bug that made opacity 0 impossible.
No Python or export-format changes, per direction.
Preview Mesh Dual:
- New control bar with Layout (Side by side | Overlay | Slider) and Mode
(Fields | Texture), two-way synced with the node widgets.
- side_by_side <-> slider reuse the SAME exported files, so that switch is
CLIENT-SIDE: onExecuted was refactored into render(message, layoutOverride)
and the last message is stored -- switching re-renders instantly, no re-run.
Overlay needs the combined export and mode changes the export format, so
those queue a re-run (same cost as editing the widget).
- opacity_1/opacity_2 widgets are hidden unless layout is Overlay (the
hidden-type trick from preview_mesh_batch_render.js), restored when it is.
Preview Mesh (VTK): new bar with Mode (Fields | Texture | Texture (PBR)),
two-way synced, re-runs on change.
Fullscreen: new createFullscreenButton(container) in utils/uiComponents.js
(fullscreens the whole widget so bar controls stay usable; listeners on the
container only, isolation-clean). Wired into Dual, VTK, and Multi bars (in
Multi it sits outside the collapsible group so it survives collapse). The
batch node keeps its own earlier implementation with arrow-key nav.
Note: plain Preview Mesh (three.js, GeomPackPreviewMesh) has no mode input on
the Python side, so there is nothing to select there; the VTK preview is the
one with modes. comfy-3d-viewers was checked for fullscreen prior art -- its
"FullScreen" hits are VTK class names, not browser fullscreen; this is new.
Both bars get a Mode dropdown mirroring the node's `mode` input, two-way
synced with the widget. Unlike index navigation this is NOT client-side: mode
changes what gets exported (VTP/STL vs GLB), so changing it queues a re-run --
the same cost as editing the node widget, just reachable from the bar. After
a run the bar reflects the mode actually executed.
- Drop /gpack/save_preview and the VTK viewer's "Save Mesh" button (added one
commit ago): previews are temp exports, and persisting a mesh is what the
SaveMesh nodes are for. The textured viewer's "Save GLB" stays -- it is a
pure client-side download, no route.
- Preview Mesh Batch (VTK) now outputs the TRIMESH batch (is_output_list),
same passthrough pattern as Preview Mesh Multi, so it can sit mid-graph.
- New GeomPackGetMeshFromBatch (nodes/io/get_mesh_from_batch.py): extract one
mesh from a TRIMESH batch by index (clamped), logging the source file_name
when present. Registered in nodes/io/__init__.py.
Save mesh: the VTK viewer's Save button POSTed to /geometrypack/save_preview,
a route that was never ported into GeometryPack (inherited caller from
comfy-3d-viewers; only /gpack/getpath existed). ComfyUI's GET catch-all
matched the path, hence 405 instead of 404. Added POST /gpack/save_preview in
__init__.py next to getpath: basename()-sanitized lookup of the temp preview
export in ComfyUI output/ (tempdir fallback), copied to output/saved_<name>;
returns {success, saved_filename}. Viewer fetch updated to the new path.
Multi cols/rows: entering dims whose product was smaller than the mesh count
(e.g. 1x1 for 2 meshes) overflowed viewports into IMPLICIT CSS grid rows
(auto height), so the rendered layout stopped matching the inputs. Now the
edited field is authoritative and the other dimension auto-raises to fit
(ceil(n/edited)); both inputs are updated to show the grid actually in
effect. Oversized grids (empty cells) remain allowed.
Adds a fullscreen button to the nav bar that expands the whole widget (viewer
+ nav bar), so the < > arrows and the dropdown stay visible and clickable in
fullscreen. Arrow KEYS (left/right) also navigate while the widget has focus
(container is made focusable and focused on entering fullscreen).
Navigation is already client-side (showIndex), so it works identically in
fullscreen with no re-execution. All listeners are on the pack's own
container element (never document), keeping it isolation-clean.
Caveat: when the mouse focus is inside the iframe 3D view, arrow KEYS go to
the iframe, not the container -- the on-screen arrow buttons always work; key
nav works when the nav bar/container holds focus. (Forwarding keys out of the
iframe would be a follow-up.)
Loading a point cloud correctly locks the shared actor to Points
representation, but the surface-mesh branch of the LOAD_MESH handler never
reset it -- so navigating from a point cloud back to a surface mesh (e.g.
scrolling a batch) left the mesh stuck rendering as points until you manually
picked Surface in settings.
Fix: in the non-point-cloud branch, restore property.setRepresentation to the
user's chosen mode (currentSettings.representation, default Surface). Point
clouds still switch to Points; switching back now returns to the chosen mode.
- "Overlay" is the tiled grid viewer, so rename it to "Grid" and make it the
default layout (was "wipe"). Saved workflows carrying the old "overlay" value
migrate to "grid" on load.
- Grid gets Cols / Rows number inputs (shown only for the grid layout). They
default to the auto dims Python computes and, when set, override them; the
viewer (viewer_multi.html) already honours gridCols/gridRows from the
LOAD_MULTI_MESH message, so changing them re-tiles the SAME iframe with no
reload and no Python/viewer change.
- The control bar is now collapsible via a small arrow (state persists in the
widget's serialized viewerState).
Client-side only (mesh_preview_multi.js). JS parses, isolation lint 0 errors.
Needs a runtime pass in ComfyUI.
The dropdown showed positions ("3 / 16"); show the source name ("apple.ply")
instead. Loaders already stash the basename in mesh.metadata['file_name']
(mesh_io.py) and it survives the worker boundary (serialization.py forwards
metadata), so the node just reads it per mesh and returns a mesh_names array.
- Python: collect names (metadata['file_name'] -> metadata['name'] -> a
positional fallback) and add "mesh_names" to the UI arrays.
- JS: dropdown options are now built as real <option> elements with
textContent = name (falling back to "k / N"), so a filename cannot inject
markup. Also added a "Name" row to the info panel (escaped).
Verified: python compiles, JS parses, isolation lint still 0 errors.
Bug: clicking a navigation arrow called app.queuePrompt() (a whole-graph
re-run). Preview nodes are V3 output nodes with no fingerprint_inputs, so
they re-execute on every queue -- which is why navigating this node reloaded
OTHER preview nodes (Preview Mesh Multi, etc.).
Fix: navigate the batch entirely client-side. The Python node now exports
EVERY mesh in the batch once and returns per-index metadata arrays
(mesh_files, vertex_counts, bounds, field_names, ...); the widget stores them
and switches meshes by posting LOAD_MESH to its OWN iframe -- no
app.queuePrompt(), so navigation never re-runs the graph or touches another
node. viewer_type is global (a function of the mode input), so switching
never needs an iframe reload either.
Also adds the requested dropdown next to the arrows: one option per mesh,
jump to any index. Arrows, dropdown, and the index widget all route through a
single client-side showIndex(); the index widget value still persists via
serialization (guarded against a re-entrant callback).
Trade-off (per the chosen approach): the batch is exported up front, so first
render is heavier for very large batches -- but navigation is then instant and
fully decoupled from the rest of the graph.
Verified: python compiles, JS parses, isolation lint still 0 errors. Needs a
runtime pass in ComfyUI (I cannot drive the node graph here).
Collapse the repeated best-effort try/except into _opt/_put helpers, flatten
the visual/metadata handling, and compress the docstring. 117 -> 86 lines
(85 -> 57 code lines). Verified behaviorally identical to the prior version
by round-tripping real trimesh meshes (plain / textured uv+material+metadata /
no-faces) through both old and new serialize+deserialize with identity recurse.
The load-bearing comments are kept verbatim in intent: the id()-reuse GC
hazard (pass mesh arrays directly, never np.asarray) and the decompose-visuals-
by-kind rationale (avoid PIL + the mesh back-reference re-serializing geometry).
Material without uv is now nested under uv -- deserialize ignores it anyway, so
the round-trip is unchanged and the wire gets slightly smaller.
- Delete 7 dead-squat widgets (registered UI only for other packs' nodes:
UniRig/SAM3D/Mocap/FBXPreview/BVH/pointcloud) + their 6 orphaned viewer
HTMLs + the entire unused js/viewer tree (21 files, imported by nothing;
only ui/styles.css survives) + unused utils barrel.
- Rename namespace geompack -> geometrypack (= DisplayName, lowercased);
all 17 surviving extensions now geometrypack.*.
- Add event.source provenance guards to every message listener (2 inline,
2 factory handlers via new iframe param).
- Stop touching document.body: upload file input stays detached (click()
works detached in modern browsers); folder autocomplete dropdown becomes
a manual popover inside the widget's own DOM (top layer escapes canvas
transforms without shared-DOM appends).
Passes comfy-test's javascript isolation lint with 0 errors.
ComfyUI auto-imports every web/**/*.js into the shared main page; the four
big bundles (three, vtk-gltf, viewer-bundle-vtk, gsplat) only ever run
inside the viewer iframes but were getting pulled into the main document
too, where they wrote window.THREE / window.vtk / globalThis.* and could
collide with any other 3D pack. The **/*.js glob only matches .js, so
renaming to .mjs drops them from the auto-import while the iframes still
load them via classic <script src> (mime resolves to text/javascript).
window.* pollution from these bundles is gone from the main realm.
The viewer JS/HTML/bundles are now committed in javascript/ (declared via
pyproject [tool.comfy] web = "javascript") instead of being copied from the
shared comfy-3d-viewers pip package at prestartup. Consequences:
- requirements.txt is now just comfy-env (host env installs nothing else,
per the never-touch-the-host-env principle)
- prestartup_script.py no longer copies viewers or web_src; it keeps only
setup_env() and the input-asset copies
- WEB_DIRECTORY attribute removed; the pyproject declaration is the single
source (served at /extensions/comfyui-geometrypack/, keyed by project name)
- web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked
source, edited directly
- viewer_pbr.html hardcoded-fallback URL updated to the new mount name
Iframe URLs auto-detect the mount from import.meta.url, so the folder rename
and mount-name change are transparent at runtime.
Prepares for removing trimesh from requirements.txt (host-env principle):
a trimesh-less host holds mesh frames opaquely and forwards them to
workers instead of crashing on ImportError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
geompack.Trimesh wire type (ADR-0014): vertices/faces ride the
shared-memory path instead of the 3-copy pickle rung; UVs + texture
material preserved (material dropped gracefully on sides lacking its
deps); vertex colors TODO. Declared via [serializers].modules, loaded
parent-side and worker-side. Validated: wire-level frames + full worker
round-trip both directions.
Two transport hazards worked around and documented in-module: the
id()-keyed visited map serves wrong frames for GC'd temporaries (pass
long-lived arrays only), and the pickle fallback returns raw objects
instead of raising (verify frames before embedding).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
comfy-env accelerator rule: a node declares at most one accelerator,
meaning 'requires this backend at execution'. Dispatcher nodes stay
undeclared (accelerator-neutral); opportunistic-GPU backends with real
CPU fallbacks stay undeclared by design.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>