@rig-c/parts 1.42.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +17 -3
- package/docs/AUTHORING.md +46 -0
- package/docs/STABILITY.md +3 -3
- package/package.json +1 -1
- package/src/remesh.ts +155 -45
- package/src/remeshdelta.ts +669 -0
package/cli.ts
CHANGED
|
@@ -344,6 +344,7 @@ usage:
|
|
|
344
344
|
|
|
345
345
|
rig-parts remesh --rig <dir> --config <config.json> --parts <dir> --part <name>
|
|
346
346
|
--mesh <entry.json> --out <dir> [--requirements <file.json>]
|
|
347
|
+
(or --delta <mesh-edit.json> in place of --mesh)
|
|
347
348
|
[--idle-keys ctl|direct] [--page-edges pot|free] [--pack-shape rect|polygon]
|
|
348
349
|
Re-fit one part's mesh of a gated rig (the directory rig writes) under
|
|
349
350
|
the mesh entry --mesh holds — what config.json would hold at
|
|
@@ -370,6 +371,18 @@ usage:
|
|
|
370
371
|
SHA-256, the part's counts before and after, both checks and their
|
|
371
372
|
scope) — no path and no clock, so the same request writes the same bytes.
|
|
372
373
|
Prints the part's counts and a hash table of every file.
|
|
374
|
+
--delta, in place of --mesh (exactly one of the two): rig-c/review's
|
|
375
|
+
mesh edit (rig-review-mesh-edit/1, basis "setup", at most 300 ops of
|
|
376
|
+
move, add, remove, edge and unedge, crop px of the rig canvas), whose
|
|
377
|
+
source counts and source.hash must be the input part's
|
|
378
|
+
(REMESH_DELTA_SOURCE). The ops are replayed in order over the input's
|
|
379
|
+
own mesh — moved vertices rebound, added ones weighted from their
|
|
380
|
+
edge's or triangle's corners, removed ones' rings re-triangulated,
|
|
381
|
+
Delaunay restored by flips that keep the hull and every constraint —
|
|
382
|
+
and the result is held to the outline rules (REMESH_DELTA_JSON,
|
|
383
|
+
REMESH_DELTA_BASIS, REMESH_DELTA_OPS, REMESH_DELTA_OP naming ops[i],
|
|
384
|
+
REMESH_DELTA_TOPOLOGY). No config entry is built: the baseline is the
|
|
385
|
+
input itself; the splice, the gate and check are as for --mesh.
|
|
373
386
|
|
|
374
387
|
rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
|
|
375
388
|
[--page-edges pot|free] [--pack-shape rect|polygon]
|
|
@@ -834,7 +847,7 @@ const TAKES = {
|
|
|
834
847
|
build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys', '--lead-in', '--alt', '--set'], switches: ['--loop', '--seam-pairs', '--keep-stills', '--all-sets'] },
|
|
835
848
|
compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
|
|
836
849
|
'mesh-draft': { valued: ['--config', '--parts', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: ['--more-triangles'] },
|
|
837
|
-
remesh: { valued: ['--rig', '--config', '--parts', '--part', '--mesh', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
|
|
850
|
+
remesh: { valued: ['--rig', '--config', '--parts', '--part', '--mesh', '--delta', '--out', '--requirements', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
|
|
838
851
|
painting: { valued: ['--keypoints', '--person'], switches: [] },
|
|
839
852
|
} as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
|
|
840
853
|
|
|
@@ -1649,8 +1662,9 @@ function cmdMeshDraft(args: string[]): number {
|
|
|
1649
1662
|
|
|
1650
1663
|
/** `remesh` (issue #495): one part of a gated rig re-fitted under a requested mesh entry, into a new revision. */
|
|
1651
1664
|
function cmdRemesh(args: string[]): number {
|
|
1652
|
-
const f = flags(args, 'remesh', ['--rig', '--config', '--parts', '--part', '--
|
|
1665
|
+
const f = flags(args, 'remesh', ['--rig', '--config', '--parts', '--part', '--out']);
|
|
1653
1666
|
if (typeof f === 'string') return usage(f);
|
|
1667
|
+
if (f.has('--mesh') === f.has('--delta')) return usage('remesh needs exactly one of --mesh, --delta');
|
|
1654
1668
|
const keys = idleKeysOf(f.get('--idle-keys'));
|
|
1655
1669
|
if (!isIdleKeys(keys)) return usage(keys);
|
|
1656
1670
|
const edges = pageEdgesOf(f.get('--page-edges'));
|
|
@@ -1671,7 +1685,7 @@ function cmdRemesh(args: string[]): number {
|
|
|
1671
1685
|
try {
|
|
1672
1686
|
const req = f.get('--requirements');
|
|
1673
1687
|
remeshStage(
|
|
1674
|
-
{ rig: f.get('--rig') as string, config: f.get('--config') as string, parts: f.get('--parts') as string, part: f.get('--part') as string, mesh: f.get('--mesh') as string, out: f.get('--out') as string, idleKeys: keys, pageEdges: edges, packShape: shape, ...(req === undefined ? {} : { requirements: req }) },
|
|
1688
|
+
{ rig: f.get('--rig') as string, config: f.get('--config') as string, parts: f.get('--parts') as string, part: f.get('--part') as string, ...(f.has('--mesh') ? { mesh: f.get('--mesh') as string } : { delta: f.get('--delta') as string }), out: f.get('--out') as string, idleKeys: keys, pageEdges: edges, packShape: shape, ...(req === undefined ? {} : { requirements: req }) },
|
|
1675
1689
|
runner,
|
|
1676
1690
|
bin,
|
|
1677
1691
|
{ parts: version(), rigc: rigcVersion },
|
package/docs/AUTHORING.md
CHANGED
|
@@ -2924,6 +2924,12 @@ exits 0, and `mesh_draft.json` and the console name the reading and the amount.
|
|
|
2924
2924
|
| <a id="REMESH_PRESERVED"></a>`REMESH_PRESERVED` | the spliced revision differs from the input outside the part's attachment and its report row; the file and the part that moved are named | a defect in this package: report it with the line |
|
|
2925
2925
|
| <a id="REMESH_RIGC_GREEN"></a>`REMESH_RIGC_GREEN` | rigc's build gate refused the revision (its lines quoted), or `rigc --version` named no version for the provenance | read rigc's line; nothing was written |
|
|
2926
2926
|
| <a id="REMESH_CHECK_HELD"></a>`REMESH_CHECK_HELD` | `check` on the revision did not measure a bar it measured on the input, or failed a bar the input met; the revision's own FAIL line is quoted | ask for a finer entry, or a different mode, for the part the line names |
|
|
2927
|
+
| <a id="REMESH_DELTA_JSON"></a>`REMESH_DELTA_JSON` | `--delta` is missing, does not parse, is not one object, its `schema` is not `rig-review-mesh-edit/1`, or `slot`, `skin`, `attachment` or `source` is not of its shape | export the mesh edit from rig-c/review as it writes it |
|
|
2928
|
+
| <a id="REMESH_DELTA_BASIS"></a>`REMESH_DELTA_BASIS` | the mesh edit's `basis` is not `"setup"`: it was drawn over a pose, and the producer binds setup positions, so it is refused rather than posed back (the basis found is quoted) | edit the mesh in setup space (rig-c/review #100) and export again |
|
|
2929
|
+
| <a id="REMESH_DELTA_OPS"></a>`REMESH_DELTA_OPS` | `ops` is not an array, or holds more than 300 ops — review's own cap per attachment | split the edit: apply the first part, then edit the revision |
|
|
2930
|
+
| <a id="REMESH_DELTA_SOURCE"></a>`REMESH_DELTA_SOURCE` | the mesh edit was not drawn over this part's mesh: its `slot` or `attachment` is not `--part`, its skin is not `default`, a `source` count (`vertexCount`, `triangleCount`, `hullLength`) or `source.hash` is not the input's (both figures quoted; the hash is SHA-256 of the part's entry in rigc's `skeleton.model.json` over the input); or the input's bindings are not where its uvs and `parts.json` place the image, so no position can be bound | export the edit from the revision you pass as `--rig` |
|
|
2931
|
+
| <a id="REMESH_DELTA_OP"></a>`REMESH_DELTA_OP` | one op, named `ops[i]` and quoted, does not apply at its place in the replay: an unknown kind, a non-finite number, an id out of range or already removed, a `from` that is not the live position within 0.001 px, a position outside the part's image, a split of an edge the producer's triangulation does not hold or outside its triangles, a free `add` outside every triangle, a move that folds its ring, a removal that leaves fewer than 3 vertices, ends a constraint or leaves a ring no ear clips, an `edge` that duplicates a constraint, is a hull edge, runs through a vertex or crosses the hull or a constraint, or an `unedge` of any edge this edit did not constrain | read the op the line names; an edge the producer flipped is not one review can unedge — redraw it with `edge`, or move the vertices |
|
|
2932
|
+
| <a id="REMESH_DELTA_TOPOLOGY"></a>`REMESH_DELTA_TOPOLOGY` | the replayed mesh breaks an outline rule (`contourTopologyProblems`' code quoted: one loop, the hull first, every triangle wound one way, no self-intersection, no coincident vertices), or holds more interior edges that are not locally Delaunay than the input did; or the input's own triangles are not wound one way | read the quoted code; the ops that made it are the edit's last ones |
|
|
2927
2933
|
|
|
2928
2934
|
A remesh changes one part's mesh and nothing else: every file of the input but
|
|
2929
2935
|
`rig.json` and `mesh_report.json` is copied as bytes, `rig.json` keeps every byte
|
|
@@ -2933,6 +2939,46 @@ names the input's SHA-256 per file, the request, both versions, the part's count
|
|
|
2933
2939
|
before and after and both checks, and quotes `check`'s own physics line as the scope
|
|
2934
2940
|
of the revision's check.
|
|
2935
2941
|
|
|
2942
|
+
**`--delta <mesh-edit.json>`** (in place of `--mesh`, exactly one of the two; issue
|
|
2943
|
+
#526) reads rig-c/review's mesh edit, `rig-review-mesh-edit/1`: `slot`, `skin`,
|
|
2944
|
+
`attachment`, `source` (`vertexCount`, `triangleCount`, `hullLength`, `hash`), `units`,
|
|
2945
|
+
`basis` and up to 300 `ops`, each one of `move {v, from, to}`, `add {at, onEdge?}`,
|
|
2946
|
+
`remove {v}`, `edge {a, b}` and `unedge {a, b}`. Positions are crop px of the rig canvas
|
|
2947
|
+
(x right, y down): a vertex's position is its part-local px (`uv × (width, height)`)
|
|
2948
|
+
plus the image's origin, `parts.json`'s `x, y` less the pad of 4. Vertex ids are the
|
|
2949
|
+
attachment's indices; an added vertex takes the next id; a removed id is never reused.
|
|
2950
|
+
`basis` must be `"setup"`. Review only records the ops; the producer retriangulates,
|
|
2951
|
+
replaying them in order over the input part's own mesh:
|
|
2952
|
+
|
|
2953
|
+
- `move`: `from` must be the live position within 0.001 px; the uv and every binding's
|
|
2954
|
+
bone-local `x, y` are recomputed from `to` through the setup frames, the shares kept.
|
|
2955
|
+
- `add` with `onEdge`: the edge is split, two triangles into four (one into two on a
|
|
2956
|
+
hull edge, whose loop gains the vertex); its shares are the two ends' blended by the
|
|
2957
|
+
parameter along the edge. Without `onEdge`: the triangle holding the point is split in
|
|
2958
|
+
three (on an edge, as `onEdge`), its shares the corners' barycentric blend. Both are
|
|
2959
|
+
rounded as the lattice rounds shares (5 places, the heaviest closing the sum).
|
|
2960
|
+
- `remove`: the vertex and its triangles go, and the ring is ear-clipped by exact
|
|
2961
|
+
orientation; a hull vertex's two hull neighbours become adjacent.
|
|
2962
|
+
- `edge`: a constraint — an existing edge is marked, a missing one is made by flipping
|
|
2963
|
+
the edges its segment crosses.
|
|
2964
|
+
- `unedge`: only an edge this edit constrained. Every other edge belongs to the
|
|
2965
|
+
producer's triangulation, which differs from the one review drew as soon as a flip
|
|
2966
|
+
runs, so an `unedge` of it is refused rather than guessed at.
|
|
2967
|
+
|
|
2968
|
+
After every op the Delaunay condition is restored by edge flips that never flip a hull
|
|
2969
|
+
edge or a constraint, and the result is measured: the outline rules and no more
|
|
2970
|
+
non-Delaunay interior edges than the input had. The output writes the hull loop first,
|
|
2971
|
+
the other vertices after in id order, uvs to 6 places and bindings as `rig` writes them;
|
|
2972
|
+
every vertex the edit neither moves nor removes keeps its uv pair and weight entries
|
|
2973
|
+
byte for byte. No config entry is built — the baseline is the input itself, held to the
|
|
2974
|
+
edit by `source`'s counts and `source.hash` (rigc's build over the input) — and the
|
|
2975
|
+
part's `mesh_report.json` row is the input's with `vertices`, `triangles`, `hull`,
|
|
2976
|
+
`bones`, `max_influences` and `mean_influences` recomputed and `mode` "delta"; its other
|
|
2977
|
+
keys describe the mesh the edit was drawn over. The splice, the preserve assertion, the
|
|
2978
|
+
gate and `check` are the `--mesh` path's. A constraint is a statement about this
|
|
2979
|
+
revision's triangulation only: the rig spec has no field for it, so a later edit of the
|
|
2980
|
+
revision reads it as any other edge.
|
|
2981
|
+
|
|
2936
2982
|
### rig-c's mesh refusals, quoted in a line
|
|
2937
2983
|
|
|
2938
2984
|
rig-c's mesh library refuses a malformed input by throwing with a code of its own. This
|
package/docs/STABILITY.md
CHANGED
|
@@ -61,7 +61,7 @@ defaults are the help's too, and none is guessed from the art.
|
|
|
61
61
|
| `build` | `--config`, `--source`, `--full`, `--out`; optional `--head` (as for `assemble`), `--seam`, `--project`, `--page-edges`, `--pack-shape`, `--loop`, `--lead-in`, `--requirements`, `--idle-keys`, `--seam-pairs`, `--keep-stills`, `--alt` (as for `assemble`, issue #454), `--set` or `--all-sets` (as for `check`, issue #454) |
|
|
62
62
|
| `compose` | `--scene`, `--out`; optional `--requirements`, `--accept` |
|
|
63
63
|
| `mesh-draft` | `--config`, `--parts`, `--out`; optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon), `--more-triangles` |
|
|
64
|
-
| `remesh` | `--rig`, `--config`, `--parts`, `--part`, `--
|
|
64
|
+
| `remesh` | `--rig`, `--config`, `--parts`, `--part`, `--out`, and one of `--mesh` or `--delta` (issue #526); optional `--requirements`, `--idle-keys` (ctl, direct), `--page-edges` (pot, free), `--pack-shape` (rect, polygon) |
|
|
65
65
|
| `painting` | one positional path, the painting; `--keypoints`; optional `--person` |
|
|
66
66
|
| `guide` | one positional argument: a rule name, `§<n>`, `§<n>.<m>` or `§<n>.<m>.<k>` |
|
|
67
67
|
| (none) | `--version`, `--help` |
|
|
@@ -198,14 +198,14 @@ every number) are AUTHORING §3's tables, and the loader refuses any other by na
|
|
|
198
198
|
| `proposal.json`, `render/landmarks*.png` | `propose` | the config's four rig sections, `constraints` with `--cloth` (issue #364: a physics constraint per tree link named `<bone>_physics`, a dangling end's `<bone>_translate`, and a skirt swing's transforms `<swing>_<tree>`), `turn` with `--full`/`--head`, `proposed_rig_scale` (issue #354), and `notes` |
|
|
199
199
|
| `basis.json` | `propose` | spec `spine-parts-basis/1` |
|
|
200
200
|
| `config.json`, `mesh_draft.json` | `mesh-draft` | `config.json` is the config read, byte for byte, but for each drafted part's `meshes` entry; `mesh_draft.json` is spec `rig-parts-mesh-draft/1`: `rules`, `lattice_check`, `draft_check`, `figure_overdraw`, and per lattice part `part`, `mode`, `lattice`, `candidate` (with `bridges` — `units`, `joins` of `span` and `area_px2`, `area_px2`, `overdraw`, and `faint` only when an island was left out as faint — only when the candidate is a bridged one, issue #326), `delta` (under `--more-triangles` only), `readings`, `gate` (each line's `reading`, `pass`, `text`, `margin`), `note`; and `not_compared` (issue #307), only when the config holds an `auto` or `contour` entry: per such entry, in config order, `part`, `mode` and `note` — the part was neither drafted nor compared, and a config of lattices alone writes no such key |
|
|
201
|
-
| `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `remesh.json` | `remesh` | the input revision's files, `rig.json` and `mesh_report.json` with the part's attachment and row replaced, every other file byte for byte; `remesh.json` is spec `rig-parts-remesh/1`: `request` (`part`, `mesh`, `mesh_sha256`, `idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `baseline` (`reproduced`, `not_compared`), `target` (`before`, `after`: `vertices`, `triangles`, `hull`), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`; `scope`) |
|
|
201
|
+
| `rig.json`, `motion.json`, `mesh_report.json`, `images/`, `remesh.json` | `remesh` | the input revision's files, `rig.json` and `mesh_report.json` with the part's attachment and row replaced (under `--delta` the row is the input's with its counts, `bones` and influences recomputed and `mode` "delta"), every other file byte for byte; `remesh.json` is spec `rig-parts-remesh/1`: `request` (`part`, `mesh`, `mesh_sha256`, `idle_keys`, `page_edges`, `pack_shape`; under `--delta`, issue #526: `part`, `delta` — the mesh edit as read — `delta_sha256`, `ops`, `applied` — `moved`, `added`, `removed`, `constrained` — `idle_keys`, `page_edges`, `pack_shape`), `versions` (`parts`, `rigc`), `input` (`rig` — `path`, `bytes`, `sha256` per file — `config_sha256`, `parts_json_sha256`), `baseline` (`reproduced`, `not_compared`; under `--delta` `reproduced` holds one sentence: no config is built, the input is the baseline), `target` (`before`, `after`: `vertices`, `triangles`, `hull`), `preserved` (`identical`, `spliced`), `revision` (per file as `input.rig`), `check` (`input`, `revision`: `pass`, `measured`, `not_met`, `skipped`, `physics`; `scope`) |
|
|
202
202
|
| the scene report | `compose` | spec `spine-parts-scene-report/1` |
|
|
203
203
|
| `idle.png`, `idle-indexed.png`, `idle.gif` | `loop`, `build --loop` | APNG and GIF encodings of the rendered idle |
|
|
204
204
|
|
|
205
205
|
Files authors write for this package, each with a spec id the reader holds:
|
|
206
206
|
`spine-parts-requirements/1` (`check --requirements`), `spine-parts-scene/1`
|
|
207
207
|
(`compose --scene`), `spine-parts-keypoints/1` (`propose --keypoints`),
|
|
208
|
-
`spine-parts-bonemap/1` (`compare --map`). The spec ids keep the package's former
|
|
208
|
+
`spine-parts-bonemap/1` (`compare --map`); and one another tool writes, read as that tool defines it: rig-c/review's `rig-review-mesh-edit/1` (`remesh --delta`). The spec ids keep the package's former
|
|
209
209
|
name on purpose: they name a file format, not the tool, and a file written before
|
|
210
210
|
the rename still reads. They do not change at 1.0.
|
|
211
211
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/remesh.ts
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* `motion` gate; for `contour` its tolerance, margin, spacing and `maxEdge`; for `grid` its spacing. There is no
|
|
16
16
|
* second vocabulary for them, so there is no second loader.
|
|
17
17
|
*
|
|
18
|
+
* `--delta <mesh-edit.json>` (issue #526), in place of `--mesh`: rig-c/review's mesh edit, ops over the input's own
|
|
19
|
+
* mesh, replayed by `src/remeshdelta.ts`. That path builds no config entry — steps 3 and 4 become the source checks
|
|
20
|
+
* (the edit's counts and `source.hash`, the SHA-256 of the part's entry in rigc's model document over the input) and
|
|
21
|
+
* the replay — and steps 5 to 8 are the same.
|
|
22
|
+
*
|
|
18
23
|
* ## The steps, each one a refusal by name
|
|
19
24
|
*
|
|
20
25
|
* 1. **The input** ({@link readTree}): `--rig` holds `rig.json`, `motion.json`, `mesh_report.json` and `images/`, as
|
|
@@ -55,12 +60,16 @@
|
|
|
55
60
|
import { createHash } from 'node:crypto';
|
|
56
61
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
57
62
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
58
|
-
import { checkStage, gateThroughRigc, type Log, rigStage } from './build.ts';
|
|
63
|
+
import { checkStage, gateThroughRigc, type Log, RIGC_MODEL_DOCUMENT, rigStage } from './build.ts';
|
|
59
64
|
import { barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackShape, type PageEdges, rigcRanClause, type RigcRunner } from './check.ts';
|
|
60
65
|
import { loadConfigAndAnimations } from './config.ts';
|
|
61
66
|
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
62
67
|
import { jsonValueSpan, scratchConfig } from './meshdraft.ts';
|
|
63
|
-
import {
|
|
68
|
+
import { computeExactFrameTransforms } from './coords.ts';
|
|
69
|
+
import { type PartsFile } from './parts.ts';
|
|
70
|
+
import { applyMeshEdit, type DeltaApplied, type DeltaPlant, deltaSourceProblems, type MeshEditDocument, readMeshEdit } from './remeshdelta.ts';
|
|
71
|
+
import { DEFAULT_IDLE_KEYS, type IdleKeys, type MeshAttachment, rigJsonText, type RigSpec } from './rig.ts';
|
|
72
|
+
import { pyRound } from './round.ts';
|
|
64
73
|
|
|
65
74
|
/** The provenance file's spec. */
|
|
66
75
|
export const REMESH_SPEC = 'rig-parts-remesh/1';
|
|
@@ -88,8 +97,10 @@ export interface RemeshInput {
|
|
|
88
97
|
parts: string;
|
|
89
98
|
/** The part to remesh: its slot and its attachment, as the rig stage names both. */
|
|
90
99
|
part: string;
|
|
91
|
-
/** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. */
|
|
92
|
-
mesh
|
|
100
|
+
/** The request's mesh entry: a JSON file holding what `config.json` would hold at `meshes.<part>`. Exactly one of `mesh`, `delta`. */
|
|
101
|
+
mesh?: string;
|
|
102
|
+
/** The request as rig-c/review's mesh edit (`rig-review-mesh-edit/1`, issue #526): ops over the input's own mesh. Exactly one of `mesh`, `delta`. */
|
|
103
|
+
delta?: string;
|
|
93
104
|
/** The new revision's directory; it must not exist. */
|
|
94
105
|
out: string;
|
|
95
106
|
idleKeys?: IdleKeys;
|
|
@@ -106,6 +117,8 @@ export interface RemeshInput {
|
|
|
106
117
|
export interface RemeshPlant {
|
|
107
118
|
perturb?: string;
|
|
108
119
|
skipPreserve?: boolean;
|
|
120
|
+
/** Issue #526's mutants, handed to the delta's replay (`src/remeshdelta.ts`). */
|
|
121
|
+
delta?: DeltaPlant;
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
/** The versions the provenance names: this package's and the rigc that gated the revision (its `--version` line). */
|
|
@@ -143,7 +156,9 @@ export interface CheckSide {
|
|
|
143
156
|
/** `remesh.json` (spec {@link REMESH_SPEC}). */
|
|
144
157
|
export interface RemeshProvenance {
|
|
145
158
|
spec: typeof REMESH_SPEC;
|
|
146
|
-
request:
|
|
159
|
+
request:
|
|
160
|
+
| { part: string; mesh: unknown; mesh_sha256: string; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape }
|
|
161
|
+
| { part: string; delta: MeshEditDocument; delta_sha256: string; ops: number; applied: Omit<DeltaApplied, 'ops'>; idle_keys: IdleKeys; page_edges: PageEdges; pack_shape: PackShape };
|
|
147
162
|
versions: RemeshVersions;
|
|
148
163
|
input: { rig: HashRow[]; config_sha256: string; parts_json_sha256: string };
|
|
149
164
|
baseline: { reproduced: string[]; not_compared: string };
|
|
@@ -232,7 +247,7 @@ export function targetProblems(rig: Json, motion: Json, configMeshes: Readonly<R
|
|
|
232
247
|
const out: Problem[] = [];
|
|
233
248
|
const att = targetAttachment(rig, part);
|
|
234
249
|
if (!isRecord(att) || att.type !== 'mesh') {
|
|
235
|
-
out.push({ code: 'REMESH_TARGET_MESH', object: `rig.json skins.default.${part}.${part}`, detail: `is ${att === undefined ? 'absent' : isRecord(att) ? `a ${
|
|
250
|
+
out.push({ code: 'REMESH_TARGET_MESH', object: `rig.json skins.default.${part}.${part}`, detail: `is ${att === undefined ? 'absent' : isRecord(att) ? `a ${typeof att.type === 'string' ? att.type : 'region'} attachment` : 'not an attachment'}; a mesh attachment is required — only a mesh is remeshed` });
|
|
236
251
|
}
|
|
237
252
|
if (!(part in configMeshes)) {
|
|
238
253
|
out.push({ code: 'REMESH_TARGET_MESH', object: `config meshes.${part}`, detail: `is absent (the config meshes ${Object.keys(configMeshes).join(', ') || 'nothing'}); the input's target must be a mesh the config states, so the baseline can reproduce it` });
|
|
@@ -474,6 +489,28 @@ export function readMeshRequest(path: string): { entry: Json; bytes: Uint8Array
|
|
|
474
489
|
return { entry, bytes };
|
|
475
490
|
}
|
|
476
491
|
|
|
492
|
+
/** The request as rig-c/review's mesh edit (issue #526), read and held to its shape (`src/remeshdelta.ts`). */
|
|
493
|
+
export function readDeltaRequest(path: string): { doc: MeshEditDocument; bytes: Uint8Array } {
|
|
494
|
+
if (!existsSync(path)) throw new PartsError([{ code: 'REMESH_DELTA_JSON', object: path, detail: 'no such file; the request\'s mesh edit (rig-c/review\'s rig-review-mesh-edit/1 document) is required' }]);
|
|
495
|
+
const bytes = new Uint8Array(readFileSync(path));
|
|
496
|
+
const { doc, problems } = readMeshEdit(text(bytes), path);
|
|
497
|
+
refuseIfAny(problems);
|
|
498
|
+
return { doc: doc as MeshEditDocument, bytes };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The hash rig-c/review binds a mesh edit to (`source.hash`): SHA-256 of `JSON.stringify` of the attachment's entry in
|
|
503
|
+
* rigc's model document (`skins[name "default"].attachments.<part>.<part>`), or null when the document holds none.
|
|
504
|
+
*/
|
|
505
|
+
export function modelAttachmentHash(modelText: string, part: string): string | null {
|
|
506
|
+
const model = JSON.parse(modelText) as { skins?: unknown };
|
|
507
|
+
const skin = Array.isArray(model.skins) ? (model.skins as unknown[]).find((s) => isRecord(s) && s.name === 'default') : undefined;
|
|
508
|
+
const atts = isRecord(skin) && isRecord(skin.attachments) ? skin.attachments : undefined;
|
|
509
|
+
const slot = atts !== undefined && isRecord(atts[part]) ? (atts[part] as Json) : undefined;
|
|
510
|
+
const entry = slot?.[part];
|
|
511
|
+
return entry === undefined ? null : createHash('sha256').update(JSON.stringify(entry)).digest('hex');
|
|
512
|
+
}
|
|
513
|
+
|
|
477
514
|
/** Step 1's tree rules: the files `rig` writes are present, and the two the splice rewrites are this writer's bytes. */
|
|
478
515
|
export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
|
|
479
516
|
const out: Problem[] = [];
|
|
@@ -496,6 +533,35 @@ export function inputTreeProblems(tree: FileTree, root: string): Problem[] {
|
|
|
496
533
|
return out;
|
|
497
534
|
}
|
|
498
535
|
|
|
536
|
+
/** A tree's images as the gate takes them (name under `images/`, bytes). */
|
|
537
|
+
const pngsOf = (tree: FileTree): Array<readonly [string, Uint8Array]> => [...tree].filter(([k]) => k.startsWith('images/')).map(([k, b]) => [k.slice('images/'.length), b] as const);
|
|
538
|
+
/** A tree's other files as text. */
|
|
539
|
+
const textsOf = (tree: FileTree): Array<readonly [string, string]> => [...tree].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The delta's `mesh_report.json` row (issue #526): the input's row, key for key, with `vertices`, `triangles`, `hull`,
|
|
543
|
+
* `bones` (the sorted set the new weights name), `max_influences` and `mean_influences` recomputed from the new
|
|
544
|
+
* attachment and `mode` "delta" (in place, or last when the row had none). Every other key is the input's and
|
|
545
|
+
* describes the mesh the delta was drawn over.
|
|
546
|
+
*/
|
|
547
|
+
export function deltaRow(row: Json, att: MeshAttachment): Json {
|
|
548
|
+
const n = att.uvs.length / 2;
|
|
549
|
+
const entries = att.weights.reduce((s, w) => s + w.length, 0);
|
|
550
|
+
const fresh: Json = {
|
|
551
|
+
vertices: n,
|
|
552
|
+
triangles: att.triangles.length / 3,
|
|
553
|
+
hull: att.hull,
|
|
554
|
+
bones: [...new Set(att.weights.flatMap((w) => w.map((e) => e.bone)))].sort(),
|
|
555
|
+
max_influences: Math.max(...att.weights.map((w) => w.length)),
|
|
556
|
+
mean_influences: pyRound(entries / n, 2),
|
|
557
|
+
mode: 'delta',
|
|
558
|
+
};
|
|
559
|
+
const out: Json = {};
|
|
560
|
+
for (const [k, v] of Object.entries(row)) out[k] = k in fresh ? fresh[k] : v;
|
|
561
|
+
if (!('mode' in out)) out.mode = 'delta';
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
499
565
|
/**
|
|
500
566
|
* The stage: steps 1–8 of the module header. Writes `input.out` only when every step is green; refuses with every
|
|
501
567
|
* problem of the first step that found one.
|
|
@@ -513,7 +579,9 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
513
579
|
if (outAt.startsWith(`${rigAt}${sep}`)) throw new PartsError([{ code: 'REMESH_OUT_FRESH', object: input.out, detail: `is inside the input ${input.rig}; a revision is written beside the input, never into it` }]);
|
|
514
580
|
const tree = readTree(rigAt);
|
|
515
581
|
refuseIfAny(inputTreeProblems(tree, input.rig));
|
|
516
|
-
|
|
582
|
+
if ((input.mesh === undefined) === (input.delta === undefined)) throw new Error('remeshStage: exactly one of mesh, delta is required');
|
|
583
|
+
const meshReq = input.mesh === undefined ? null : readMeshRequest(input.mesh);
|
|
584
|
+
const deltaReq = input.delta === undefined ? null : readDeltaRequest(input.delta);
|
|
517
585
|
const configText = readFileSync(input.config, 'utf8');
|
|
518
586
|
const { config: cfg } = loadConfigAndAnimations(input.config);
|
|
519
587
|
const inRig = JSON.parse(text(tree.get('rig.json') as Uint8Array)) as Json;
|
|
@@ -521,41 +589,85 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
521
589
|
const inReport = JSON.parse(text(tree.get('mesh_report.json') as Uint8Array)) as Json[];
|
|
522
590
|
// 2. The target.
|
|
523
591
|
refuseIfAny(targetProblems(inRig, JSON.parse(inMotionText) as Json, cfg.meshes, part));
|
|
524
|
-
// The request is the config's to judge: spliced in and loaded, before anything is built.
|
|
525
|
-
const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, request.entry]]));
|
|
526
|
-
loadConfigAndAnimations(candConfig);
|
|
527
|
-
const auto = 'auto' in request.entry;
|
|
528
|
-
const build = (name: string, config: string): Built => {
|
|
529
|
-
const out = join(scratch, name, 'rig');
|
|
530
|
-
rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
|
|
531
|
-
return readBuilt(out, part);
|
|
532
|
-
};
|
|
533
|
-
// 3. The baseline.
|
|
534
|
-
log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
|
|
535
|
-
let base: Built;
|
|
536
|
-
try {
|
|
537
|
-
base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
|
|
538
|
-
} catch (err) {
|
|
539
|
-
if (!(err instanceof PartsError)) throw err;
|
|
540
|
-
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_BASELINE_MISMATCH', object: `${input.config} as written`, detail: `does not build, so it cannot reproduce the input: ${problemLine(p)}` })));
|
|
541
|
-
}
|
|
542
|
-
refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
|
|
543
|
-
log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
|
|
544
|
-
// 4. The candidate.
|
|
545
|
-
let cand: Built;
|
|
546
|
-
try {
|
|
547
|
-
cand = build('candidate', candConfig);
|
|
548
|
-
} catch (err) {
|
|
549
|
-
if (!(err instanceof PartsError)) throw err;
|
|
550
|
-
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_CANDIDATE_REFUSED', object: `the request's meshes.${part} (${input.mesh})`, detail: `the producer refuses it, nothing is written: ${problemLine(p)}` })));
|
|
551
|
-
}
|
|
552
|
-
refuseIfAny(requestHeldProblems(base, cand, part));
|
|
553
|
-
const candRow = rowOf(cand.report, part);
|
|
554
|
-
if (candRow === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
|
|
555
592
|
const before = meshCounts(targetAttachment(inRig, part));
|
|
556
|
-
|
|
593
|
+
let candAtt: unknown;
|
|
594
|
+
let candRow: Json;
|
|
595
|
+
let reproduced: string[];
|
|
596
|
+
let deltaRequest: RemeshProvenance['request'] | null = null;
|
|
597
|
+
if (meshReq !== null) {
|
|
598
|
+
const meshPath = input.mesh as string;
|
|
599
|
+
// The request is the config's to judge: spliced in and loaded, before anything is built.
|
|
600
|
+
const candConfig = scratchConfig(input.config, configText, join(scratch, 'candidate'), new Map([[part, meshReq.entry]]));
|
|
601
|
+
loadConfigAndAnimations(candConfig);
|
|
602
|
+
const auto = 'auto' in meshReq.entry;
|
|
603
|
+
const build = (name: string, config: string): Built => {
|
|
604
|
+
const out = join(scratch, name, 'rig');
|
|
605
|
+
rigStage({ config, parts: input.parts, out, idleKeys, pageEdges, packShape }, rigc, join(scratch, name, 'gate'), (l) => log(`[${name}:rig] ${l}`));
|
|
606
|
+
return readBuilt(out, part);
|
|
607
|
+
};
|
|
608
|
+
// 3. The baseline.
|
|
609
|
+
log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the baseline is the config as written`);
|
|
610
|
+
let base: Built;
|
|
611
|
+
try {
|
|
612
|
+
base = build('baseline', scratchConfig(input.config, configText, join(scratch, 'baseline'), new Map()));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (!(err instanceof PartsError)) throw err;
|
|
615
|
+
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_BASELINE_MISMATCH', object: `${input.config} as written`, detail: `does not build, so it cannot reproduce the input: ${problemLine(p)}` })));
|
|
616
|
+
}
|
|
617
|
+
refuseIfAny(baselineProblems({ rig: inRig, motionText: inMotionText, report: inReport, image: tree.get(`images/${part}.png`) }, base, part, auto));
|
|
618
|
+
log(` baseline: the config as written reproduces the input's ${part} (attachment, image, report row) and its bones${auto ? ' and motion' : ''}`);
|
|
619
|
+
// 4. The candidate.
|
|
620
|
+
let cand: Built;
|
|
621
|
+
try {
|
|
622
|
+
cand = build('candidate', candConfig);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (!(err instanceof PartsError)) throw err;
|
|
625
|
+
throw new PartsError(err.problems.map((p) => ({ code: 'REMESH_CANDIDATE_REFUSED', object: `the request's meshes.${part} (${meshPath})`, detail: `the producer refuses it, nothing is written: ${problemLine(p)}` })));
|
|
626
|
+
}
|
|
627
|
+
refuseIfAny(requestHeldProblems(base, cand, part));
|
|
628
|
+
const row = rowOf(cand.report, part);
|
|
629
|
+
if (row === undefined) throw new Error(`remesh: the candidate build wrote no mesh_report row for "${part}"`);
|
|
630
|
+
candAtt = targetAttachment(cand.rig, part);
|
|
631
|
+
candRow = row;
|
|
632
|
+
reproduced = [`skins.default.${part}.${part}`, `images/${part}.png`, `mesh_report.json row "${part}"`, 'bones', ...(auto ? ['motion.json'] : [])];
|
|
633
|
+
} else {
|
|
634
|
+
// 3–4 on the delta path (issue #526): no config entry to build, so the baseline is the input itself, held to the
|
|
635
|
+
// delta by its source counts and its source.hash, and the candidate is the delta replayed over the input's mesh.
|
|
636
|
+
const deltaPath = input.delta as string;
|
|
637
|
+
const { doc, bytes } = deltaReq as { doc: MeshEditDocument; bytes: Uint8Array };
|
|
638
|
+
const inAtt = targetAttachment(inRig, part) as MeshAttachment;
|
|
639
|
+
refuseIfAny(deltaSourceProblems(doc, part, inAtt, deltaPath));
|
|
640
|
+
log(`rig-parts remesh: ${part} of ${input.rig}, ${tree.size} file(s); the request is a mesh edit of ${doc.ops.length} op(s), the baseline is the input itself`);
|
|
641
|
+
let model = null as string | null;
|
|
642
|
+
const inGate = gateThroughRigc(pngsOf(tree), textsOf(tree), rigc, join(scratch, 'gate-input'), { pageEdges, packShape }, (dir) => {
|
|
643
|
+
const p = join(dir, RIGC_MODEL_DOCUMENT);
|
|
644
|
+
model = existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
645
|
+
});
|
|
646
|
+
refuseIfAny(inGate.filter((g) => g.status !== 0).map((g) => ({ code: 'REMESH_RIGC_GREEN', object: `rigc ${g.label} over the input`, detail: `exited ${g.status}${g.lines.length > 0 ? `: ${g.lines.map((l) => l.trim()).join(' | ')}` : ''}${rigcRanClause(g)}; the delta's source.hash is read off the input's own build, and nothing was written` })));
|
|
647
|
+
if (model === null) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `rigc's build over the input`, detail: `wrote no ${RIGC_MODEL_DOCUMENT}, so the delta's source.hash cannot be verified; nothing was written` }]);
|
|
648
|
+
const hash = modelAttachmentHash(model, part);
|
|
649
|
+
if (hash !== doc.source.hash) {
|
|
650
|
+
throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${deltaPath} source.hash`, detail: `is ${doc.source.hash.slice(0, 16)}…; the input's ${part} in its ${RIGC_MODEL_DOCUMENT} hashes to ${hash === null ? 'nothing (no such entry)' : `${hash.slice(0, 16)}…`} — the delta was drawn over another mesh` }]);
|
|
651
|
+
}
|
|
652
|
+
log(` source: ${part}'s entry in the input's ${RIGC_MODEL_DOCUMENT} hashes to ${hash.slice(0, 16)}…, the delta's source.hash; counts v=${before.vertices} t=${before.triangles} hull=${before.hull} as it states`);
|
|
653
|
+
const partsFile = JSON.parse(readFileSync(join(input.parts, 'parts.json'), 'utf8')) as PartsFile;
|
|
654
|
+
const rec = partsFile.parts.find((p) => p.name === part);
|
|
655
|
+
if (rec === undefined) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${input.parts}/parts.json`, detail: `names no part "${part}"; its box is where rig placed the image, which the delta's crop px are read against` }]);
|
|
656
|
+
const world = computeExactFrameTransforms((inRig as unknown as RigSpec).bones);
|
|
657
|
+
const r = applyMeshEdit({ part, attachment: inAtt, box: [rec.x, rec.y], rigSize: [partsFile.rig_size[0], partsFile.rig_size[1]], world }, doc, deltaPath, plant.delta);
|
|
658
|
+
if (Array.isArray(r)) throw new PartsError(r);
|
|
659
|
+
const row = rowOf(inReport, part);
|
|
660
|
+
if (row === undefined) throw new PartsError([{ code: 'REMESH_DELTA_SOURCE', object: `${input.rig}/mesh_report.json`, detail: `holds no row "${part}"; the delta's row is the input's with its counts recomputed` }]);
|
|
661
|
+
candAtt = r.attachment;
|
|
662
|
+
candRow = deltaRow(row, r.attachment);
|
|
663
|
+
const { ops, ...applied } = r.applied;
|
|
664
|
+
log(` delta: ${ops} op(s) — moved ${applied.moved}, added ${applied.added}, removed ${applied.removed}, constrained ${applied.constrained}; ${r.untouched.length} source vertex(es) untouched; interior edges not locally Delaunay ${r.delaunay.before} -> ${r.delaunay.after}`);
|
|
665
|
+
reproduced = ['none built: the delta path builds no config entry, so the baseline is the input itself, held to the delta by source.vertexCount, source.triangleCount, source.hullLength and source.hash'];
|
|
666
|
+
deltaRequest = { part, delta: doc, delta_sha256: sha256(bytes), ops, applied, idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape };
|
|
667
|
+
}
|
|
668
|
+
const after = meshCounts(candAtt);
|
|
557
669
|
// 5–6. The splice and the preserve assertion.
|
|
558
|
-
let revision = splice(tree, part,
|
|
670
|
+
let revision = splice(tree, part, candAtt, candRow);
|
|
559
671
|
if (plant.perturb !== undefined) revision = perturbed(revision, plant.perturb);
|
|
560
672
|
if (plant.skipPreserve !== true) refuseIfAny(preservedProblems(tree, revision, part));
|
|
561
673
|
const spliced = ['rig.json', 'mesh_report.json'];
|
|
@@ -563,9 +675,7 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
563
675
|
log(` ${part}: v=${before.vertices} t=${before.triangles} hull=${before.hull} -> v=${after.vertices} t=${after.triangles} hull=${after.hull}`);
|
|
564
676
|
log(` preserved: ${identical.length} file(s) byte-identical to the input; rig.json identical outside skins.default.${part}.${part}; mesh_report.json identical but the "${part}" row`);
|
|
565
677
|
// 7. The gate and check.
|
|
566
|
-
const
|
|
567
|
-
const texts = [...revision].filter(([k]) => !k.startsWith('images/')).map(([k, b]) => [k, text(b)] as const);
|
|
568
|
-
const gate = gateThroughRigc(pngs, texts, rigc, join(scratch, 'gate'), { pageEdges, packShape });
|
|
678
|
+
const gate = gateThroughRigc(pngsOf(revision), textsOf(revision), rigc, join(scratch, 'gate'), { pageEdges, packShape });
|
|
569
679
|
for (const g of gate) {
|
|
570
680
|
log(` rigc ${g.label}, the revision: exit ${g.status}`);
|
|
571
681
|
for (const l of g.lines) log(` ${l.trim()}`);
|
|
@@ -587,11 +697,11 @@ export function remeshStage(input: RemeshInput, rigc: RigcRunner, checkBin: stri
|
|
|
587
697
|
// 8. The write.
|
|
588
698
|
const provenance: RemeshProvenance = {
|
|
589
699
|
spec: REMESH_SPEC,
|
|
590
|
-
request: { part, mesh:
|
|
700
|
+
request: deltaRequest ?? { part, mesh: (meshReq as { entry: Json }).entry, mesh_sha256: sha256((meshReq as { bytes: Uint8Array }).bytes), idle_keys: idleKeys, page_edges: pageEdges, pack_shape: packShape },
|
|
591
701
|
versions,
|
|
592
702
|
input: { rig: hashRows(tree), config_sha256: sha256(bytesOf(configText)), parts_json_sha256: sha256(new Uint8Array(readFileSync(join(input.parts, 'parts.json')))) },
|
|
593
703
|
baseline: {
|
|
594
|
-
reproduced
|
|
704
|
+
reproduced,
|
|
595
705
|
not_compared: 'every other attachment, image and report row: carried over from the input as bytes, whether or not the config reproduces it',
|
|
596
706
|
},
|
|
597
707
|
target: { before, after },
|
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-parts remesh --delta` (issue #526): the reader of rig-c/review's mesh edit, `rig-review-mesh-edit/1` — a typed
|
|
3
|
+
* list of vertex and edge ops a person drew over one part's mesh in review's Edit Mesh — and the producer half of that
|
|
4
|
+
* loop: the ops replayed, in order, over the input part's own triangulation, retriangulated here (review never
|
|
5
|
+
* retriangulates; its document says "the producer retriangulates"), and written back as a `mesh` attachment in this
|
|
6
|
+
* package's form. Pure: no I/O; `src/remesh.ts` reads the files and splices the result.
|
|
7
|
+
*
|
|
8
|
+
* ## The document
|
|
9
|
+
*
|
|
10
|
+
* `{ schema, slot, skin, attachment, source: { vertexCount, triangleCount, hullLength, hash }, units, basis, preview,
|
|
11
|
+
* ops }` as review writes it (`src/mesh-edit.ts`, `meshEditDocument`). Positions are crop px of the rig canvas, x
|
|
12
|
+
* right, y down: a vertex's crop position is its part-local px (`uv × (width, height)`) plus the part's origin
|
|
13
|
+
* (`parts.json` x − {@link PAD}, y − PAD, the origin `rig` placed the image at). Vertex ids are the attachment's
|
|
14
|
+
* indices; an added vertex takes the next id; a removed id is never reused. `basis` must be `"setup"` (rig-c/review
|
|
15
|
+
* #100): a posed basis would put setup vertices where a pose drew them, so it is refused rather than posed back.
|
|
16
|
+
*
|
|
17
|
+
* ## The ops, as the producer applies them
|
|
18
|
+
*
|
|
19
|
+
* - `move {v, from, to}`: `from` must be the live position (within {@link FROM_TOLERANCE_PX}); the uv and every
|
|
20
|
+
* binding's bone-local x, y are recomputed from `to` through the setup frames; the shares are kept. Then the
|
|
21
|
+
* Delaunay condition is restored by edge flips around it, never flipping a hull or constraint edge.
|
|
22
|
+
* - `add {at, onEdge}`: the edge split (two triangles to four; one to two on a hull edge, the hull loop gaining the
|
|
23
|
+
* vertex), weights the two ends' shares blended by the parameter along the edge; `add {at}` without `onEdge`: the
|
|
24
|
+
* triangle that holds it split in three (on an edge, as `onEdge`), weights the corners' shares by barycentric blend.
|
|
25
|
+
* - `remove {v}`: the vertex and its triangles dropped and the ring ear-clipped by exact orientation, then flipped;
|
|
26
|
+
* a hull vertex's two hull neighbours become adjacent.
|
|
27
|
+
* - `edge {a, b}`: a constraint — the edge marked, or made by flipping the edges it crosses (edge recovery); one that
|
|
28
|
+
* crosses the hull or another constraint is refused.
|
|
29
|
+
* - `unedge {a, b}`: only an edge this delta constrained; the producer owns every other edge of the triangulation.
|
|
30
|
+
*
|
|
31
|
+
* Every op that does not apply is refused by name with its index (`REMESH_DELTA_OP`), and the result is held to the
|
|
32
|
+
* outline rules and the Delaunay count by {@link contourTopologyProblems} and {@link delaunayViolations}
|
|
33
|
+
* (`REMESH_DELTA_TOPOLOGY`) — measured on the result, not trusted from the replay.
|
|
34
|
+
*
|
|
35
|
+
* ## Coordinates
|
|
36
|
+
*
|
|
37
|
+
* Two copies of every position are kept: the exact one (part-local px, a double), which the uv, the bindings and the
|
|
38
|
+
* `from` comparison read, and its snap to {@link GRID} units, which every predicate reads, so orientation and the
|
|
39
|
+
* in-circle test are exact (`src/contour.ts`). A vertex the delta neither moves nor removes keeps its uv pair and its
|
|
40
|
+
* weight entries byte for byte; only its index may change, since the hull loop is written first.
|
|
41
|
+
*/
|
|
42
|
+
import { contourTopologyProblems, delaunayViolations, GRID, inCircle } from './contour.ts';
|
|
43
|
+
import { type BoneTransform, cropToSpineY, toBoneLocal } from './coords.ts';
|
|
44
|
+
import type { Problem } from './errors.ts';
|
|
45
|
+
import { type MeshAttachment, OFFSET_PLACES, PAD, roundShares, type WeightEntry } from './rig.ts';
|
|
46
|
+
import { pyRound } from './round.ts';
|
|
47
|
+
import type { Influence } from './weights.ts';
|
|
48
|
+
|
|
49
|
+
/** The schema review writes (rig-c/review `src/mesh-edit.ts`, `MESH_EDIT_SCHEMA`). */
|
|
50
|
+
export const MESH_EDIT_SCHEMA = 'rig-review-mesh-edit/1';
|
|
51
|
+
|
|
52
|
+
/** The basis the reader accepts (rig-c/review #100: the editor works in setup space). */
|
|
53
|
+
export const MESH_EDIT_BASIS = 'setup';
|
|
54
|
+
|
|
55
|
+
/** Review's own cap on one attachment's ops (`MESH_EDIT_MAX_OPS`: the draft rides in one form field). */
|
|
56
|
+
export const MESH_EDIT_MAX_OPS = 300;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How far `move.from` may sit from the live position, in crop px: the uv is written to 6 places, which moves a
|
|
60
|
+
* vertex by up to 2.5e-4 px on the parts measured (the public demo's 9 meshes, 1,192 vertices), so 1e-3 holds every
|
|
61
|
+
* honest `from` and refuses a delta drawn over another mesh. The same tolerance holds the input's own bindings to
|
|
62
|
+
* the position its uv states ({@link applyMeshEdit}'s reading of the part's origin).
|
|
63
|
+
*/
|
|
64
|
+
export const FROM_TOLERANCE_PX = 1e-3;
|
|
65
|
+
|
|
66
|
+
export type Vec = [number, number];
|
|
67
|
+
export type MeshEditOp =
|
|
68
|
+
| { op: 'move'; v: number; from: Vec; to: Vec }
|
|
69
|
+
| { op: 'add'; at: Vec; onEdge?: [number, number] }
|
|
70
|
+
| { op: 'remove'; v: number }
|
|
71
|
+
| { op: 'edge'; a: number; b: number }
|
|
72
|
+
| { op: 'unedge'; a: number; b: number };
|
|
73
|
+
|
|
74
|
+
export interface MeshEditDocument {
|
|
75
|
+
schema: typeof MESH_EDIT_SCHEMA;
|
|
76
|
+
slot: string;
|
|
77
|
+
skin: string;
|
|
78
|
+
attachment: string;
|
|
79
|
+
source: { vertexCount: number; triangleCount: number; hullLength: number; hash: string };
|
|
80
|
+
units: string;
|
|
81
|
+
basis: string;
|
|
82
|
+
preview: string;
|
|
83
|
+
ops: MeshEditOp[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Json = Record<string, unknown>;
|
|
87
|
+
const isRecord = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
88
|
+
const isVec = (v: unknown): v is Vec => Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
89
|
+
const isId = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
90
|
+
const OP_FIELDS: Record<MeshEditOp['op'], readonly string[]> = { move: ['op', 'v', 'from', 'to'], add: ['op', 'at', 'onEdge'], remove: ['op', 'v'], edge: ['op', 'a', 'b'], unedge: ['op', 'a', 'b'] };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The document, read and held to its shape: `schema` (`REMESH_DELTA_JSON`), `basis` (`REMESH_DELTA_BASIS`), `ops` an
|
|
94
|
+
* array of at most {@link MESH_EDIT_MAX_OPS} (`REMESH_DELTA_OPS`), each op of a known kind with finite numbers and
|
|
95
|
+
* whole ids (`REMESH_DELTA_OP`). Every problem is collected and thrown once; whether an id is live is the replay's.
|
|
96
|
+
*/
|
|
97
|
+
export function readMeshEdit(text: string, object: string): { doc: MeshEditDocument | null; problems: Problem[] } {
|
|
98
|
+
const problems: Problem[] = [];
|
|
99
|
+
let value: unknown;
|
|
100
|
+
try {
|
|
101
|
+
value = JSON.parse(text);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { doc: null, problems: [{ code: 'REMESH_DELTA_JSON', object, detail: `does not parse as JSON: ${(err as Error).message}` }] };
|
|
104
|
+
}
|
|
105
|
+
if (!isRecord(value)) return { doc: null, problems: [{ code: 'REMESH_DELTA_JSON', object, detail: `is ${Array.isArray(value) ? 'an array' : JSON.stringify(value)}; one ${MESH_EDIT_SCHEMA} document is required` }] };
|
|
106
|
+
if (value.schema !== MESH_EDIT_SCHEMA) problems.push({ code: 'REMESH_DELTA_JSON', object: `${object} schema`, detail: `is ${JSON.stringify(value.schema)}; "${MESH_EDIT_SCHEMA}" (rig-c/review's mesh edit) is required` });
|
|
107
|
+
for (const k of ['slot', 'skin', 'attachment']) if (typeof value[k] !== 'string') problems.push({ code: 'REMESH_DELTA_JSON', object: `${object} ${k}`, detail: `is ${JSON.stringify(value[k])}; a string is required` });
|
|
108
|
+
const src = value.source;
|
|
109
|
+
if (!isRecord(src) || !isId(src.vertexCount) || !isId(src.triangleCount) || !isId(src.hullLength) || typeof src.hash !== 'string') {
|
|
110
|
+
problems.push({ code: 'REMESH_DELTA_JSON', object: `${object} source`, detail: `is ${JSON.stringify(src)}; { vertexCount, triangleCount, hullLength: whole numbers, hash: a string } is required` });
|
|
111
|
+
}
|
|
112
|
+
if (value.basis !== MESH_EDIT_BASIS) {
|
|
113
|
+
problems.push({ code: 'REMESH_DELTA_BASIS', object: `${object} basis`, detail: `is ${JSON.stringify(value.basis)}; "${MESH_EDIT_BASIS}" is required — the producer binds setup positions, and a delta drawn over a pose (review before rig-c/review #100) is refused rather than posed back` });
|
|
114
|
+
}
|
|
115
|
+
const ops = value.ops;
|
|
116
|
+
if (!Array.isArray(ops)) problems.push({ code: 'REMESH_DELTA_OPS', object: `${object} ops`, detail: `is ${JSON.stringify(ops)}; an array of ops is required` });
|
|
117
|
+
else if (ops.length > MESH_EDIT_MAX_OPS) problems.push({ code: 'REMESH_DELTA_OPS', object: `${object} ops`, detail: `holds ${ops.length} ops; at most ${MESH_EDIT_MAX_OPS} (review's own cap per attachment) are read` });
|
|
118
|
+
else {
|
|
119
|
+
ops.forEach((op: unknown, i: number) => {
|
|
120
|
+
const bad = (why: string): void => {
|
|
121
|
+
problems.push({ code: 'REMESH_DELTA_OP', object: `${object} ops[${i}]`, detail: `${JSON.stringify(op)} ${why}` });
|
|
122
|
+
};
|
|
123
|
+
if (!isRecord(op) || typeof op.op !== 'string' || !(op.op in OP_FIELDS)) {
|
|
124
|
+
bad(`has an unknown op kind ${isRecord(op) ? JSON.stringify(op.op) : ''}; one of move, add, remove, edge, unedge is required`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const kind = op.op as MeshEditOp['op'];
|
|
128
|
+
const extra = Object.keys(op).filter((k) => !OP_FIELDS[kind].includes(k));
|
|
129
|
+
if (extra.length > 0) bad(`carries ${extra.join(', ')}, which a ${kind} op does not take`);
|
|
130
|
+
if (kind === 'move') {
|
|
131
|
+
if (!isId(op.v)) bad('needs a whole vertex id v');
|
|
132
|
+
if (!isVec(op.from) || !isVec(op.to)) bad('needs finite from and to pairs');
|
|
133
|
+
} else if (kind === 'add') {
|
|
134
|
+
if (!isVec(op.at)) bad('needs a finite at pair');
|
|
135
|
+
if (op.onEdge !== undefined && !(Array.isArray(op.onEdge) && op.onEdge.length === 2 && op.onEdge.every(isId))) bad('needs onEdge as a pair of whole vertex ids, when it is given');
|
|
136
|
+
} else if (kind === 'remove') {
|
|
137
|
+
if (!isId(op.v)) bad('needs a whole vertex id v');
|
|
138
|
+
} else if (!isId(op.a) || !isId(op.b)) bad('needs whole vertex ids a and b');
|
|
139
|
+
else if (op.a === op.b) bad('joins a vertex to itself');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return { doc: problems.length === 0 ? (value as unknown as MeshEditDocument) : null, problems };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The input the replay reads: the part's attachment, where `rig` placed it, and the setup frames of the rig's bones. */
|
|
146
|
+
export interface DeltaInput {
|
|
147
|
+
part: string;
|
|
148
|
+
attachment: MeshAttachment;
|
|
149
|
+
/** `parts.json`'s box corner (x, y) for the part: the image sits at (x − PAD, y − PAD). */
|
|
150
|
+
box: Vec;
|
|
151
|
+
/** `parts.json`'s `rig_size`. */
|
|
152
|
+
rigSize: Vec;
|
|
153
|
+
/** Every bone's setup frame, as `computeExactFrameTransforms` gives it over `rig.json`'s bones. */
|
|
154
|
+
world: ReadonlyMap<string, BoneTransform>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Planted faults for the selftest (issue #526) — never set by the CLI. */
|
|
158
|
+
export interface DeltaPlant {
|
|
159
|
+
/** A move keeps its bindings' x, y (the bone-local recompute skipped). */
|
|
160
|
+
skipBind?: boolean;
|
|
161
|
+
/** The output keeps id order instead of writing the hull loop first. */
|
|
162
|
+
skipHullOrder?: boolean;
|
|
163
|
+
/** The result's topology gate is skipped. */
|
|
164
|
+
skipTopology?: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface DeltaApplied {
|
|
168
|
+
ops: number;
|
|
169
|
+
moved: number;
|
|
170
|
+
added: number;
|
|
171
|
+
removed: number;
|
|
172
|
+
constrained: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface DeltaResult {
|
|
176
|
+
attachment: MeshAttachment;
|
|
177
|
+
counts: { vertices: number; triangles: number; hull: number };
|
|
178
|
+
applied: DeltaApplied;
|
|
179
|
+
/** Source ids the delta neither moved nor removed: their uv pair and weight entries are the input's bytes. */
|
|
180
|
+
untouched: number[];
|
|
181
|
+
/** Interior edges not locally Delaunay, before and after (constraint edges not asked). */
|
|
182
|
+
delaunay: { before: number; after: number };
|
|
183
|
+
/** The output index of every live id, in id order (removed ids absent). */
|
|
184
|
+
index: Array<[number, number]>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const placed6 = (v: number): number => {
|
|
188
|
+
const r = pyRound(v, OFFSET_PLACES);
|
|
189
|
+
return r === 0 ? 0 : r;
|
|
190
|
+
};
|
|
191
|
+
const ekey = (a: number, b: number): string => (a < b ? `${a}:${b}` : `${b}:${a}`);
|
|
192
|
+
|
|
193
|
+
/** Thrown inside the replay; caught once by {@link applyMeshEdit} and turned into its problem. */
|
|
194
|
+
class OpRefused extends Error {
|
|
195
|
+
constructor(
|
|
196
|
+
readonly code: string,
|
|
197
|
+
readonly at: string,
|
|
198
|
+
readonly why: string,
|
|
199
|
+
) {
|
|
200
|
+
super(why);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The source checks a document is held to before any op is replayed: it names this part's attachment in the default
|
|
206
|
+
* skin, and its source counts are this attachment's (`REMESH_DELTA_SOURCE`, naming the field and both figures).
|
|
207
|
+
*/
|
|
208
|
+
export function deltaSourceProblems(doc: MeshEditDocument, part: string, att: MeshAttachment, object: string): Problem[] {
|
|
209
|
+
const out: Problem[] = [];
|
|
210
|
+
for (const k of ['slot', 'attachment'] as const) if (doc[k] !== part) out.push({ code: 'REMESH_DELTA_SOURCE', object: `${object} ${k}`, detail: `is "${doc[k]}"; the delta must name --part "${part}", whose slot and attachment the rig stage names after the part` });
|
|
211
|
+
if (doc.skin !== 'default') out.push({ code: 'REMESH_DELTA_SOURCE', object: `${object} skin`, detail: `is "${doc.skin}"; this package writes every mesh into the default skin, so "default" is required` });
|
|
212
|
+
const counts: Array<[string, number, number]> = [
|
|
213
|
+
['vertexCount', doc.source.vertexCount, att.uvs.length / 2],
|
|
214
|
+
['triangleCount', doc.source.triangleCount, att.triangles.length / 3],
|
|
215
|
+
['hullLength', doc.source.hullLength, att.hull],
|
|
216
|
+
];
|
|
217
|
+
for (const [k, said, is] of counts) if (said !== is) out.push({ code: 'REMESH_DELTA_SOURCE', object: `${object} source.${k}`, detail: `is ${said}; the input's ${part} has ${is} — the delta was drawn over another mesh` });
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The replay: the document's ops over the input's mesh, in order, and the result held to the outline and Delaunay
|
|
223
|
+
* rules. Returns the problems (the first op that does not apply ends the replay, since every later op reads its
|
|
224
|
+
* state) or the new attachment.
|
|
225
|
+
*/
|
|
226
|
+
export function applyMeshEdit(input: DeltaInput, doc: MeshEditDocument, object: string, plant: DeltaPlant = {}): DeltaResult | Problem[] {
|
|
227
|
+
const att = input.attachment;
|
|
228
|
+
const { width: w, height: h } = att;
|
|
229
|
+
const ox = input.box[0] - PAD;
|
|
230
|
+
const oy = input.box[1] - PAD;
|
|
231
|
+
const CX = input.rigSize[0] / 2;
|
|
232
|
+
const H = input.rigSize[1];
|
|
233
|
+
const V0 = att.uvs.length / 2;
|
|
234
|
+
// Exact positions (part px) and their grid snaps, by id; the uv and weights to write, by id.
|
|
235
|
+
const PX: number[] = [];
|
|
236
|
+
const PY: number[] = [];
|
|
237
|
+
const X: number[] = [];
|
|
238
|
+
const Y: number[] = [];
|
|
239
|
+
const uv: Vec[] = [];
|
|
240
|
+
const wt: WeightEntry[][] = [];
|
|
241
|
+
const live: boolean[] = [];
|
|
242
|
+
const moved = new Set<number>();
|
|
243
|
+
const added = new Set<number>();
|
|
244
|
+
const removed = new Set<number>();
|
|
245
|
+
const snap = (v: number): number => Math.round(v * GRID);
|
|
246
|
+
const setPos = (id: number, x: number, y: number): void => {
|
|
247
|
+
PX[id] = x;
|
|
248
|
+
PY[id] = y;
|
|
249
|
+
X[id] = snap(x);
|
|
250
|
+
Y[id] = snap(y);
|
|
251
|
+
};
|
|
252
|
+
const bind = (shares: ReadonlyArray<{ bone: string; weight: number }>, x: number, y: number): WeightEntry[] =>
|
|
253
|
+
shares.map(({ bone, weight }) => {
|
|
254
|
+
const m = input.world.get(bone);
|
|
255
|
+
if (m === undefined) throw new OpRefused('REMESH_DELTA_SOURCE', `${input.part}'s weights`, `name bone "${bone}", which rig.json does not declare`);
|
|
256
|
+
const [bx, by] = toBoneLocal(m, ox + x - CX, cropToSpineY(oy + y, H));
|
|
257
|
+
return { bone, x: placed6(bx), y: placed6(by), weight };
|
|
258
|
+
});
|
|
259
|
+
const problems: Problem[] = [];
|
|
260
|
+
for (let i = 0; i < V0; i++) {
|
|
261
|
+
setPos(i, att.uvs[2 * i] * w, att.uvs[2 * i + 1] * h);
|
|
262
|
+
uv.push([att.uvs[2 * i], att.uvs[2 * i + 1]]);
|
|
263
|
+
wt.push(att.weights[i]);
|
|
264
|
+
live.push(true);
|
|
265
|
+
}
|
|
266
|
+
// The part's origin is read, not trusted: every input binding must be what its uv states at (ox, oy).
|
|
267
|
+
try {
|
|
268
|
+
for (let i = 0; i < V0 && problems.length === 0; i++) {
|
|
269
|
+
const again = bind(att.weights[i], PX[i], PY[i]);
|
|
270
|
+
const off = again.findIndex((e, k) => Math.abs(e.x - att.weights[i][k].x) > FROM_TOLERANCE_PX || Math.abs(e.y - att.weights[i][k].y) > FROM_TOLERANCE_PX);
|
|
271
|
+
if (off >= 0) {
|
|
272
|
+
const e = att.weights[i][off];
|
|
273
|
+
problems.push({ code: 'REMESH_DELTA_SOURCE', object: `rig.json skins.default.${input.part}.${input.part} vertex ${i}`, detail: `binds to "${e.bone}" at (${e.x}, ${e.y}); its uv at the part's origin (${ox}, ${oy}) binds at (${again[off].x}, ${again[off].y}) — the input's placement is not the one parts.json states, so no position the delta names can be bound` });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (!(err instanceof OpRefused)) throw err;
|
|
278
|
+
problems.push({ code: err.code, object: err.at, detail: err.why });
|
|
279
|
+
}
|
|
280
|
+
if (problems.length > 0) return problems;
|
|
281
|
+
|
|
282
|
+
// Triangles in the replay's winding: orient > 0 in y-down grid units. The input's winding is measured and kept.
|
|
283
|
+
const orient = (a: number, b: number, c: number): number => (X[b] - X[a]) * (Y[c] - Y[a]) - (Y[b] - Y[a]) * (X[c] - X[a]);
|
|
284
|
+
let sign = 0;
|
|
285
|
+
for (let t = 0; t < att.triangles.length; t += 3) {
|
|
286
|
+
const s = Math.sign(orient(att.triangles[t], att.triangles[t + 1], att.triangles[t + 2]));
|
|
287
|
+
if (s === 0 || (sign !== 0 && s !== sign)) {
|
|
288
|
+
return [{ code: 'REMESH_DELTA_TOPOLOGY', object: `rig.json skins.default.${input.part}.${input.part} triangle ${t / 3}`, detail: s === 0 ? 'has zero area in the input; the replay needs every input triangle wound one way' : 'is wound against the triangles before it in the input; the replay needs every input triangle wound one way' }];
|
|
289
|
+
}
|
|
290
|
+
sign = s;
|
|
291
|
+
}
|
|
292
|
+
const tris: Array<[number, number, number] | null> = [];
|
|
293
|
+
const owner = new Map<string, number>();
|
|
294
|
+
const dkey = (a: number, b: number): string => `${a},${b}`;
|
|
295
|
+
const link = (t: number): void => {
|
|
296
|
+
const r = tris[t] as [number, number, number];
|
|
297
|
+
owner.set(dkey(r[0], r[1]), t);
|
|
298
|
+
owner.set(dkey(r[1], r[2]), t);
|
|
299
|
+
owner.set(dkey(r[2], r[0]), t);
|
|
300
|
+
};
|
|
301
|
+
const unlink = (t: number): void => {
|
|
302
|
+
const r = tris[t];
|
|
303
|
+
if (r === null || r === undefined) return;
|
|
304
|
+
// a key the triangle no longer owns (a flip's partner took it first) is left to its owner.
|
|
305
|
+
for (const k of [dkey(r[0], r[1]), dkey(r[1], r[2]), dkey(r[2], r[0])]) if (owner.get(k) === t) owner.delete(k);
|
|
306
|
+
};
|
|
307
|
+
const setTri = (t: number, a: number, b: number, c: number): void => {
|
|
308
|
+
unlink(t);
|
|
309
|
+
tris[t] = [a, b, c];
|
|
310
|
+
link(t);
|
|
311
|
+
};
|
|
312
|
+
for (let t = 0; t < att.triangles.length; t += 3) {
|
|
313
|
+
const [a, b, c] = [att.triangles[t], att.triangles[t + 1], att.triangles[t + 2]];
|
|
314
|
+
if (sign > 0) setTri(t / 3, a, b, c);
|
|
315
|
+
else setTri(t / 3, a, c, b);
|
|
316
|
+
}
|
|
317
|
+
const freeSlots: number[] = [];
|
|
318
|
+
const newTri = (a: number, b: number, c: number): number => {
|
|
319
|
+
const t = freeSlots.length > 0 ? (freeSlots.shift() as number) : tris.length;
|
|
320
|
+
setTri(t, a, b, c);
|
|
321
|
+
return t;
|
|
322
|
+
};
|
|
323
|
+
const dropTri = (t: number): void => {
|
|
324
|
+
unlink(t);
|
|
325
|
+
tris[t] = null;
|
|
326
|
+
freeSlots.push(t);
|
|
327
|
+
freeSlots.sort((p, q) => p - q);
|
|
328
|
+
};
|
|
329
|
+
const third = (t: number, a: number, b: number): number => {
|
|
330
|
+
const r = tris[t] as [number, number, number];
|
|
331
|
+
return r.find((x) => x !== a && x !== b) as number;
|
|
332
|
+
};
|
|
333
|
+
const hull: number[] = Array.from({ length: att.hull }, (_, i) => i);
|
|
334
|
+
const constrained = new Set<string>();
|
|
335
|
+
const isEdge = (a: number, b: number): boolean => owner.has(dkey(a, b)) || owner.has(dkey(b, a));
|
|
336
|
+
const isHullEdge = (a: number, b: number): boolean => isEdge(a, b) && (!owner.has(dkey(a, b)) || !owner.has(dkey(b, a)));
|
|
337
|
+
const starOf = (v: number): number[] => tris.flatMap((r, t) => (r !== null && r.includes(v) ? [t] : []));
|
|
338
|
+
const violations = (): number => {
|
|
339
|
+
const n = (r: readonly number[]): Vec[] => r.map((id) => [X[id] / GRID, Y[id] / GRID] as Vec);
|
|
340
|
+
const ids = X.map((_, i) => i);
|
|
341
|
+
const flat = tris.flatMap((r) => (r === null ? [] : r));
|
|
342
|
+
return delaunayViolations(n(ids), flat, [...constrained].map((k) => k.split(':').map(Number) as [number, number]));
|
|
343
|
+
};
|
|
344
|
+
const before = violations();
|
|
345
|
+
|
|
346
|
+
/** Lawson flips from the queued edges: an interior, unconstrained edge whose far vertex lies inside the circle is flipped. */
|
|
347
|
+
const legalize = (queue: Array<[number, number]>): void => {
|
|
348
|
+
let guard = 0;
|
|
349
|
+
while (queue.length > 0) {
|
|
350
|
+
if (++guard > 1_000_000) throw new Error('remeshdelta: edge flips did not settle');
|
|
351
|
+
const [a, b] = queue.pop() as [number, number];
|
|
352
|
+
if (constrained.has(ekey(a, b))) continue;
|
|
353
|
+
const t = owner.get(dkey(a, b));
|
|
354
|
+
const u = owner.get(dkey(b, a));
|
|
355
|
+
if (t === undefined || u === undefined) continue;
|
|
356
|
+
const c = third(t, a, b);
|
|
357
|
+
const d = third(u, b, a);
|
|
358
|
+
if (inCircle(X[a], Y[a], X[b], Y[b], X[c], Y[c], X[d], Y[d]) <= 0n) continue;
|
|
359
|
+
if (!(orient(a, d, c) > 0 && orient(b, c, d) > 0)) continue;
|
|
360
|
+
setTri(t, a, d, c);
|
|
361
|
+
setTri(u, b, c, d);
|
|
362
|
+
queue.push([a, d], [d, b], [b, c], [c, a]);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const edgesOf = (ts: readonly number[]): Array<[number, number]> =>
|
|
366
|
+
ts.flatMap((t) => {
|
|
367
|
+
const r = tris[t];
|
|
368
|
+
return r === null || r === undefined ? [] : ([[r[0], r[1]], [r[1], r[2]], [r[2], r[0]]] as Array<[number, number]>);
|
|
369
|
+
});
|
|
370
|
+
const liveCount = (): number => live.filter((x) => x).length;
|
|
371
|
+
const crop = (id: number): Vec => [PX[id] + ox, PY[id] + oy];
|
|
372
|
+
const inImage = (x: number, y: number): boolean => x >= 0 && x <= w && y >= 0 && y <= h;
|
|
373
|
+
const mustLive = (id: number, what: string): void => {
|
|
374
|
+
if (id >= live.length) throw new OpRefused('REMESH_DELTA_OP', '', `names ${what} ${id}, which is out of range (ids 0..${live.length - 1} exist at this op)`);
|
|
375
|
+
if (!live[id]) throw new OpRefused('REMESH_DELTA_OP', '', `names ${what} ${id}, which an earlier op removed`);
|
|
376
|
+
};
|
|
377
|
+
const blend = (parts: ReadonlyArray<readonly [number, number]>): Influence[] => {
|
|
378
|
+
const by = new Map<string, number>();
|
|
379
|
+
for (const [id, f] of parts) for (const e of wt[id]) by.set(e.bone, (by.get(e.bone) ?? 0) + e.weight * f);
|
|
380
|
+
const sum = [...by.values()].reduce((s, x) => s + x, 0);
|
|
381
|
+
return roundShares([...by].map(([bone, weight]) => ({ bone, weight: weight / sum })));
|
|
382
|
+
};
|
|
383
|
+
const addVertex = (x: number, y: number, shares: Influence[]): number => {
|
|
384
|
+
const id = live.length;
|
|
385
|
+
setPos(id, x, y);
|
|
386
|
+
live.push(true);
|
|
387
|
+
uv.push([pyRound(x / w, 6), pyRound(y / h, 6)]);
|
|
388
|
+
wt.push(bind(shares, x, y));
|
|
389
|
+
added.add(id);
|
|
390
|
+
return id;
|
|
391
|
+
};
|
|
392
|
+
/** The split of edge a–b at (x, y): its one or two triangles become two or four. */
|
|
393
|
+
const splitEdge = (a: number, b: number, x: number, y: number): void => {
|
|
394
|
+
const t = owner.get(dkey(a, b));
|
|
395
|
+
const u = owner.get(dkey(b, a));
|
|
396
|
+
const dx = PX[b] - PX[a];
|
|
397
|
+
const dy = PY[b] - PY[a];
|
|
398
|
+
const s = Math.min(1, Math.max(0, ((x - PX[a]) * dx + (y - PY[a]) * dy) / (dx * dx + dy * dy)));
|
|
399
|
+
const sx = snap(x);
|
|
400
|
+
const sy = snap(y);
|
|
401
|
+
const sides: Array<[number, number, number]> = [];
|
|
402
|
+
if (t !== undefined) sides.push([a, b, third(t, a, b)]);
|
|
403
|
+
if (u !== undefined) sides.push([b, a, third(u, b, a)]);
|
|
404
|
+
for (const [p, q, c] of sides) {
|
|
405
|
+
// the new point must be strictly inside the triangles' union on each side: (p, new, c) and (new, q, c) wound > 0.
|
|
406
|
+
const o1 = (sx - X[p]) * (Y[c] - Y[p]) - (sy - Y[p]) * (X[c] - X[p]);
|
|
407
|
+
const o2 = (X[q] - sx) * (Y[c] - sy) - (Y[q] - sy) * (X[c] - sx);
|
|
408
|
+
if (!(o1 > 0 && o2 > 0)) throw new OpRefused('REMESH_DELTA_OP', '', `puts the vertex at crop (${pyRound(x + ox, 4)}, ${pyRound(y + oy, 4)}), outside the triangles on edge ${a}-${b} (it would fold triangle ${p}, ${q}, ${c})`);
|
|
409
|
+
}
|
|
410
|
+
const id = addVertex(x, y, blend([[a, 1 - s], [b, s]]));
|
|
411
|
+
const made: number[] = [];
|
|
412
|
+
if (t !== undefined) {
|
|
413
|
+
const c = third(t, a, b);
|
|
414
|
+
setTri(t, a, id, c);
|
|
415
|
+
made.push(t, newTri(id, b, c));
|
|
416
|
+
}
|
|
417
|
+
if (u !== undefined) {
|
|
418
|
+
const d = third(u, b, a);
|
|
419
|
+
setTri(u, b, id, d);
|
|
420
|
+
made.push(u, newTri(id, a, d));
|
|
421
|
+
}
|
|
422
|
+
if (t === undefined || u === undefined) {
|
|
423
|
+
const ia = hull.indexOf(a);
|
|
424
|
+
const ib = hull.indexOf(b);
|
|
425
|
+
const n = hull.length;
|
|
426
|
+
if (ia < 0 || ib < 0 || ((ia + 1) % n !== ib && (ib + 1) % n !== ia)) throw new Error(`remeshdelta: boundary edge ${a}-${b} is not consecutive on the hull loop`);
|
|
427
|
+
const first = (ia + 1) % n === ib ? ia : ib;
|
|
428
|
+
hull.splice(first + 1, 0, id);
|
|
429
|
+
}
|
|
430
|
+
legalize(edgesOf(made));
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const ops = doc.ops;
|
|
434
|
+
let constrainedCount = 0;
|
|
435
|
+
for (let i = 0; i < ops.length; i++) {
|
|
436
|
+
const op = ops[i];
|
|
437
|
+
try {
|
|
438
|
+
if (op.op === 'move') {
|
|
439
|
+
mustLive(op.v, 'vertex');
|
|
440
|
+
const [fx, fy] = crop(op.v);
|
|
441
|
+
if (Math.abs(fx - op.from[0]) > FROM_TOLERANCE_PX || Math.abs(fy - op.from[1]) > FROM_TOLERANCE_PX) {
|
|
442
|
+
throw new OpRefused('REMESH_DELTA_OP', '', `moves vertex ${op.v} from (${op.from[0]}, ${op.from[1]}); its live position is (${pyRound(fx, 4)}, ${pyRound(fy, 4)}) — from does not match within ${FROM_TOLERANCE_PX} px, so the delta was made over another mesh`);
|
|
443
|
+
}
|
|
444
|
+
const x = op.to[0] - ox;
|
|
445
|
+
const y = op.to[1] - oy;
|
|
446
|
+
if (!inImage(x, y)) throw new OpRefused('REMESH_DELTA_OP', '', `moves vertex ${op.v} to crop (${op.to[0]}, ${op.to[1]}), outside the part's image (crop ${ox}..${ox + w} x ${oy}..${oy + h}); the texture has no sample there`);
|
|
447
|
+
const keep: Vec = [PX[op.v], PY[op.v]];
|
|
448
|
+
const keepG: Vec = [X[op.v], Y[op.v]];
|
|
449
|
+
setPos(op.v, x, y);
|
|
450
|
+
const star = starOf(op.v);
|
|
451
|
+
const folded = star.find((t) => {
|
|
452
|
+
const r = tris[t] as [number, number, number];
|
|
453
|
+
return orient(r[0], r[1], r[2]) <= 0;
|
|
454
|
+
});
|
|
455
|
+
if (folded !== undefined) {
|
|
456
|
+
PX[op.v] = keep[0];
|
|
457
|
+
PY[op.v] = keep[1];
|
|
458
|
+
X[op.v] = keepG[0];
|
|
459
|
+
Y[op.v] = keepG[1];
|
|
460
|
+
throw new OpRefused('REMESH_DELTA_OP', '', `moves vertex ${op.v} across an edge of its own ring: triangle ${(tris[folded] as number[]).join(', ')} would fold; move it in smaller steps, or remove and add it`);
|
|
461
|
+
}
|
|
462
|
+
uv[op.v] = [pyRound(x / w, 6), pyRound(y / h, 6)];
|
|
463
|
+
if (plant.skipBind !== true) wt[op.v] = bind(wt[op.v], x, y);
|
|
464
|
+
moved.add(op.v);
|
|
465
|
+
legalize(edgesOf(star));
|
|
466
|
+
} else if (op.op === 'add') {
|
|
467
|
+
const x = op.at[0] - ox;
|
|
468
|
+
const y = op.at[1] - oy;
|
|
469
|
+
if (!inImage(x, y)) throw new OpRefused('REMESH_DELTA_OP', '', `adds a vertex at crop (${op.at[0]}, ${op.at[1]}), outside the part's image (crop ${ox}..${ox + w} x ${oy}..${oy + h}); the texture has no sample there`);
|
|
470
|
+
const sx = snap(x);
|
|
471
|
+
const sy = snap(y);
|
|
472
|
+
if (op.onEdge !== undefined) {
|
|
473
|
+
const [a, b] = op.onEdge;
|
|
474
|
+
mustLive(a, 'vertex');
|
|
475
|
+
mustLive(b, 'vertex');
|
|
476
|
+
if (a === b) throw new OpRefused('REMESH_DELTA_OP', '', `splits edge ${a}-${b}, which joins a vertex to itself`);
|
|
477
|
+
if (!isEdge(a, b)) throw new OpRefused('REMESH_DELTA_OP', '', `splits a missing edge ${a}-${b}: the producer's triangulation has no such edge at this op`);
|
|
478
|
+
splitEdge(a, b, x, y);
|
|
479
|
+
} else {
|
|
480
|
+
let placedIn = false;
|
|
481
|
+
for (let t = 0; t < tris.length && !placedIn; t++) {
|
|
482
|
+
const r = tris[t];
|
|
483
|
+
if (r === null) continue;
|
|
484
|
+
const [a, b, c] = r;
|
|
485
|
+
const ob = (p: number, q: number): number => (X[q] - X[p]) * (sy - Y[p]) - (Y[q] - Y[p]) * (sx - X[p]);
|
|
486
|
+
const o1 = ob(a, b);
|
|
487
|
+
const o2 = ob(b, c);
|
|
488
|
+
const o3 = ob(c, a);
|
|
489
|
+
if (o1 < 0 || o2 < 0 || o3 < 0) continue;
|
|
490
|
+
const zeros = [o1, o2, o3].filter((z) => z === 0).length;
|
|
491
|
+
if (zeros >= 2) {
|
|
492
|
+
const at = [a, b, c].find((id) => X[id] === sx && Y[id] === sy);
|
|
493
|
+
throw new OpRefused('REMESH_DELTA_OP', '', `adds a vertex at crop (${op.at[0]}, ${op.at[1]}), on vertex ${at ?? a}; distinct positions are required`);
|
|
494
|
+
}
|
|
495
|
+
placedIn = true;
|
|
496
|
+
if (zeros === 1) {
|
|
497
|
+
const [p, q] = o1 === 0 ? [a, b] : o2 === 0 ? [b, c] : [c, a];
|
|
498
|
+
splitEdge(p, q, x, y);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
const area = orient(a, b, c);
|
|
502
|
+
const id = addVertex(x, y, blend([[a, o2 / area], [b, o3 / area], [c, o1 / area]]));
|
|
503
|
+
setTri(t, a, b, id);
|
|
504
|
+
const t2 = newTri(b, c, id);
|
|
505
|
+
const t3 = newTri(c, a, id);
|
|
506
|
+
legalize(edgesOf([t, t2, t3]));
|
|
507
|
+
}
|
|
508
|
+
if (!placedIn) throw new OpRefused('REMESH_DELTA_OP', '', `adds a free vertex at crop (${op.at[0]}, ${op.at[1]}), outside every triangle of the mesh; a vertex outside the mesh is added on a hull edge (onEdge)`);
|
|
509
|
+
}
|
|
510
|
+
} else if (op.op === 'remove') {
|
|
511
|
+
const v = op.v;
|
|
512
|
+
mustLive(v, 'vertex');
|
|
513
|
+
const held = [...constrained].find((k) => k.split(':').map(Number).includes(v));
|
|
514
|
+
if (held !== undefined) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, an end of constraint edge ${held.replace(':', '-')}; unedge it first`);
|
|
515
|
+
const onHull = hull.includes(v);
|
|
516
|
+
if (liveCount() - 1 < 3 || (onHull && hull.length - 1 < 3)) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, which would leave ${onHull && hull.length - 1 < 3 ? `a hull of ${hull.length - 1}` : `${liveCount() - 1} vertices`}; at least 3 are required`);
|
|
517
|
+
const star = starOf(v);
|
|
518
|
+
const next = new Map<number, number>();
|
|
519
|
+
for (const t of star) {
|
|
520
|
+
const r = tris[t] as [number, number, number];
|
|
521
|
+
const k = r.indexOf(v);
|
|
522
|
+
next.set(r[(k + 1) % 3], r[(k + 2) % 3]);
|
|
523
|
+
}
|
|
524
|
+
const ys = new Set(next.values());
|
|
525
|
+
const starts = [...next.keys()].filter((x) => !ys.has(x));
|
|
526
|
+
const start = onHull ? starts[0] : (tris[star[0]] as number[])[((tris[star[0]] as number[]).indexOf(v) + 1) % 3];
|
|
527
|
+
if (start === undefined || (onHull && starts.length !== 1) || (!onHull && starts.length !== 0)) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, whose ring is not one ${onHull ? 'open' : 'closed'} fan`);
|
|
528
|
+
const ring: number[] = [start];
|
|
529
|
+
for (let cur = start; next.has(cur); ) {
|
|
530
|
+
cur = next.get(cur) as number;
|
|
531
|
+
if (cur === start) break;
|
|
532
|
+
ring.push(cur);
|
|
533
|
+
if (ring.length > star.length + 1) throw new Error(`remeshdelta: the ring of vertex ${v} does not close`);
|
|
534
|
+
}
|
|
535
|
+
if (onHull) {
|
|
536
|
+
const n = hull.length;
|
|
537
|
+
const k = hull.indexOf(v);
|
|
538
|
+
if (ring[0] !== hull[(k + 1) % n] || ring[ring.length - 1] !== hull[(k - 1 + n) % n]) throw new OpRefused('REMESH_DELTA_OP', '', `removes hull vertex ${v}, whose fan does not run from one hull neighbour to the other`);
|
|
539
|
+
}
|
|
540
|
+
// ear clipping, exact: the polygon is wound > 0; the first ear in ring order is cut each time.
|
|
541
|
+
const poly = [...ring];
|
|
542
|
+
const made: Array<[number, number, number]> = [];
|
|
543
|
+
while (poly.length > 3) {
|
|
544
|
+
let cut = -1;
|
|
545
|
+
for (let j = 0; j < poly.length && cut < 0; j++) {
|
|
546
|
+
const p = poly[(j - 1 + poly.length) % poly.length];
|
|
547
|
+
const c = poly[j];
|
|
548
|
+
const q = poly[(j + 1) % poly.length];
|
|
549
|
+
if (orient(p, c, q) <= 0) continue;
|
|
550
|
+
const blocked = poly.some((r) => r !== p && r !== c && r !== q && orient(p, c, r) >= 0 && orient(c, q, r) >= 0 && orient(q, p, r) >= 0);
|
|
551
|
+
if (!blocked) cut = j;
|
|
552
|
+
}
|
|
553
|
+
if (cut < 0) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, whose ring (${poly.join(', ')}) has no ear to clip: it cannot be triangulated without the vertex`);
|
|
554
|
+
made.push([poly[(cut - 1 + poly.length) % poly.length], poly[cut], poly[(cut + 1) % poly.length]]);
|
|
555
|
+
poly.splice(cut, 1);
|
|
556
|
+
}
|
|
557
|
+
if (poly.length === 3) {
|
|
558
|
+
if (orient(poly[0], poly[1], poly[2]) <= 0) throw new OpRefused('REMESH_DELTA_OP', '', `removes vertex ${v}, whose ring closes on a flat triangle (${poly.join(', ')})`);
|
|
559
|
+
made.push([poly[0], poly[1], poly[2]]);
|
|
560
|
+
}
|
|
561
|
+
for (const t of star) dropTri(t);
|
|
562
|
+
live[v] = false;
|
|
563
|
+
removed.add(v);
|
|
564
|
+
moved.delete(v);
|
|
565
|
+
if (onHull) hull.splice(hull.indexOf(v), 1);
|
|
566
|
+
const ts = made.map(([a, b, c]) => newTri(a, b, c));
|
|
567
|
+
legalize(edgesOf(ts));
|
|
568
|
+
} else if (op.op === 'edge') {
|
|
569
|
+
const { a, b } = op;
|
|
570
|
+
mustLive(a, 'vertex');
|
|
571
|
+
mustLive(b, 'vertex');
|
|
572
|
+
const key = ekey(a, b);
|
|
573
|
+
if (constrained.has(key)) throw new OpRefused('REMESH_DELTA_OP', '', `duplicates constraint edge ${a}-${b}`);
|
|
574
|
+
if (isHullEdge(a, b)) throw new OpRefused('REMESH_DELTA_OP', '', `constrains hull edge ${a}-${b}, which no flip ever removes`);
|
|
575
|
+
if (!isEdge(a, b)) {
|
|
576
|
+
const on = live.findIndex((l, k) => l && k !== a && k !== b && orient(a, b, k) === 0 && (X[k] - X[a]) * (X[b] - X[k]) + (Y[k] - Y[a]) * (Y[b] - Y[k]) > 0);
|
|
577
|
+
if (on >= 0) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, whose segment runs through vertex ${on}; constrain ${a}-${on} and ${on}-${b}`);
|
|
578
|
+
const changed = new Set<number>();
|
|
579
|
+
let guard = 0;
|
|
580
|
+
for (;;) {
|
|
581
|
+
if (isEdge(a, b)) break;
|
|
582
|
+
if (++guard > 4 * tris.length + 16) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, which edge flips could not recover`);
|
|
583
|
+
let crossing: { t: number; u: number; p: number; q: number } | null = null;
|
|
584
|
+
let flipped = false;
|
|
585
|
+
for (let t = 0; t < tris.length && !flipped; t++) {
|
|
586
|
+
const r = tris[t];
|
|
587
|
+
if (r === null) continue;
|
|
588
|
+
for (let k = 0; k < 3 && !flipped; k++) {
|
|
589
|
+
const p = r[k];
|
|
590
|
+
const q = r[(k + 1) % 3];
|
|
591
|
+
if (p === a || p === b || q === a || q === b) continue;
|
|
592
|
+
if (!(Math.sign(orient(a, b, p)) * Math.sign(orient(a, b, q)) < 0 && Math.sign(orient(p, q, a)) * Math.sign(orient(p, q, b)) < 0)) continue;
|
|
593
|
+
const u = owner.get(dkey(q, p));
|
|
594
|
+
if (u === undefined) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, whose segment crosses hull edge ${p}-${q}: it would leave the mesh`);
|
|
595
|
+
if (constrained.has(ekey(p, q))) throw new OpRefused('REMESH_DELTA_OP', '', `constrains ${a}-${b}, whose segment crosses constraint edge ${p}-${q}`);
|
|
596
|
+
crossing ??= { t, u, p, q };
|
|
597
|
+
const c = third(t, p, q);
|
|
598
|
+
const d = third(u, q, p);
|
|
599
|
+
if (!(orient(p, d, c) > 0 && orient(q, c, d) > 0)) continue;
|
|
600
|
+
setTri(t, p, d, c);
|
|
601
|
+
setTri(u, q, c, d);
|
|
602
|
+
changed.add(t).add(u);
|
|
603
|
+
flipped = true;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!flipped) throw new OpRefused('REMESH_DELTA_OP', '', crossing === null ? `constrains ${a}-${b}, whose segment crosses no edge and is no edge: it runs outside the mesh` : `constrains ${a}-${b}: no edge it crosses can be flipped`);
|
|
607
|
+
}
|
|
608
|
+
constrained.add(key);
|
|
609
|
+
legalize(edgesOf([...changed]));
|
|
610
|
+
} else constrained.add(key);
|
|
611
|
+
constrainedCount++;
|
|
612
|
+
} else {
|
|
613
|
+
const { a, b } = op;
|
|
614
|
+
mustLive(a, 'vertex');
|
|
615
|
+
mustLive(b, 'vertex');
|
|
616
|
+
if (isHullEdge(a, b)) throw new OpRefused('REMESH_DELTA_OP', '', `unedges hull edge ${a}-${b}; the hull is the mesh's outline and is never removed`);
|
|
617
|
+
if (!constrained.has(ekey(a, b))) throw new OpRefused('REMESH_DELTA_OP', '', `unedges ${a}-${b}: unedge names an edge this delta did not constrain; the producer owns the triangulation`);
|
|
618
|
+
constrained.delete(ekey(a, b));
|
|
619
|
+
constrainedCount--;
|
|
620
|
+
legalize([[a, b]]);
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
if (!(err instanceof OpRefused)) throw err;
|
|
624
|
+
return [{ code: err.code, object: err.at === '' ? `${object} ops[${i}]` : err.at, detail: `${JSON.stringify(op)} ${err.why}` }];
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// The output: the hull loop first, in order, then every other live vertex in id order.
|
|
629
|
+
const ids = X.map((_, i) => i).filter((i) => live[i]);
|
|
630
|
+
const order = plant.skipHullOrder === true ? ids : [...hull, ...ids.filter((i) => !hull.includes(i))];
|
|
631
|
+
const at = new Map(order.map((id, k) => [id, k]));
|
|
632
|
+
const triangles: number[] = [];
|
|
633
|
+
for (const r of tris) {
|
|
634
|
+
if (r === null) continue;
|
|
635
|
+
const [a, b, c] = r.map((id) => at.get(id) as number);
|
|
636
|
+
if (sign > 0) triangles.push(a, b, c);
|
|
637
|
+
else triangles.push(a, c, b);
|
|
638
|
+
}
|
|
639
|
+
const attachment: MeshAttachment = {
|
|
640
|
+
type: 'mesh',
|
|
641
|
+
image: att.image,
|
|
642
|
+
width: w,
|
|
643
|
+
height: h,
|
|
644
|
+
uvs: order.flatMap((id) => uv[id]),
|
|
645
|
+
triangles,
|
|
646
|
+
hull: hull.length,
|
|
647
|
+
weights: order.map((id) => wt[id]),
|
|
648
|
+
};
|
|
649
|
+
const after = violations();
|
|
650
|
+
if (plant.skipTopology !== true) {
|
|
651
|
+
const verts = order.map((id) => [X[id] / GRID, Y[id] / GRID] as Vec);
|
|
652
|
+
const topo = contourTopologyProblems(input.part, verts, triangles, hull.length);
|
|
653
|
+
const out: Problem[] = topo.map((p) => ({ code: 'REMESH_DELTA_TOPOLOGY', object: `the replayed ${input.part}`, detail: `${p.code}: ${p.detail}; the result is measured, not trusted, and nothing is written` }));
|
|
654
|
+
if (after > before) out.push({ code: 'REMESH_DELTA_TOPOLOGY', object: `the replayed ${input.part}`, detail: `has ${after} interior edge(s) not locally Delaunay (constraint edges not asked) where the input has ${before}; the replay may not add one, and nothing is written` });
|
|
655
|
+
if (out.length > 0) return out;
|
|
656
|
+
}
|
|
657
|
+
const untouched = X.map((_, i) => i).filter((i) => i < V0 && live[i] && !moved.has(i));
|
|
658
|
+
for (const id of untouched) {
|
|
659
|
+
if (JSON.stringify(uv[id]) !== JSON.stringify([att.uvs[2 * id], att.uvs[2 * id + 1]]) || JSON.stringify(wt[id]) !== JSON.stringify(att.weights[id])) throw new Error(`remeshdelta: untouched vertex ${id} changed`);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
attachment,
|
|
663
|
+
counts: { vertices: order.length, triangles: triangles.length / 3, hull: hull.length },
|
|
664
|
+
applied: { ops: ops.length, moved: moved.size, added: added.size, removed: removed.size, constrained: constrainedCount },
|
|
665
|
+
untouched,
|
|
666
|
+
delaunay: { before, after },
|
|
667
|
+
index: ids.map((id) => [id, at.get(id) as number]),
|
|
668
|
+
};
|
|
669
|
+
}
|