@ikijs/editor 0.31.0 → 0.32.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/README.md CHANGED
@@ -102,7 +102,8 @@ hair sway), validated against `RIG_PLAN_FIELDS`. `options.patches` adds displace
102
102
  stops (`RigPatch`), replayed on every rig and reported through
103
103
  `options.onPatchesApplied`; a refused op throws `PatchError`. `options.customParts` adds parts the
104
104
  auto-rigger has no role for (`CustomPart`: `id`, `layer`, `anchor`, `tie`,
105
- `after`), unrigged on `headDeformer` with a plain mesh and drawn after the
105
+ `after`, and `occlusion`: roles whose parts hide it by an amount keyed on a
106
+ parameter), unrigged on `headDeformer` with a plain mesh and drawn after the
106
107
  `after` role's last part; the rest of the model is as without them but for
107
108
  the `order` of every part after an inserted one (a set rigged without
108
109
  `customParts` is byte-identical), and a malformed entry throws
package/dist/index.d.mts CHANGED
@@ -251,10 +251,11 @@ declare class AddDeformer implements EditCommand {
251
251
  * visually predictable.
252
252
  *
253
253
  * No `parseIkiModel` pre-check is needed: removing an element from an already-
254
- * valid model cannot introduce a structural violation — EXCEPT for clip-mask
255
- * references, the one part→part reference in the contract. `apply` refuses to
256
- * delete a part still used as another part's `clip.masks` entry (guard below),
257
- * so it can never leave a dangling mask ref that would fail `toIkiModel()`.
254
+ * valid model cannot introduce a structural violation — EXCEPT for the part→part
255
+ * references in the contract: `clip.masks` and `occlusion.by`. `apply` refuses to
256
+ * delete a part still used as another part's `clip.masks` or `occlusion.by`
257
+ * entry (guards below), so it can never leave a dangling ref that would fail
258
+ * `toIkiModel()`.
258
259
  *
259
260
  * Texture-reference safety (package invariant): `apply` refuses to delete a
260
261
  * part that still carries `part.texture`. Texture/atlas state is non-undoable
@@ -1081,6 +1082,19 @@ interface CustomPart {
1081
1082
  /** The art does not bend (a hairpin, a ribbon): a bake that bends it more
1082
1083
  * than 0.0025 face widths at a stop is refused (`BakeError`). */
1083
1084
  rigid?: boolean;
1085
+ /** Hide the part where the parts of these roles cover it, by an amount keyed on a
1086
+ * parameter (`IkiPart.occlusion`). `by` lists roles of the set: the
1087
+ * generator writes the part ids those roles yield. The parameter is one
1088
+ * the model declares and not a physics output; the stops follow the
1089
+ * format's rules. */
1090
+ occlusion?: {
1091
+ by: string[];
1092
+ parameter: string;
1093
+ stops: {
1094
+ value: number;
1095
+ amount: number;
1096
+ }[];
1097
+ };
1084
1098
  }
1085
1099
  /** A `customParts` entry that is malformed or misses the layer set. Its
1086
1100
  * message starts `customParts[i].<field>` (`customParts` alone for a
@@ -1917,10 +1931,11 @@ declare function validateDeformerReparent(deformers: IkiDeformer[], deformerId:
1917
1931
  * `toIkiModel()` refuses.
1918
1932
  *
1919
1933
  * Note: there is no validatePartDelete here, but NOT because parts are
1920
- * unreferenced — `clip.masks` names parts by id. That invariant is enforced
1921
- * closer to the edits that could break it: `DeletePart` refuses to remove a
1922
- * part still used as a mask, and `SetPartMesh` refuses to strip the mesh off
1923
- * one (masks must be mesh parts). No command creates or edits `clip`.
1934
+ * unreferenced — `clip.masks` and `occlusion.by` name parts by id. That
1935
+ * invariant is enforced closer to the edits that could break it: `DeletePart`
1936
+ * refuses to remove a part still used as a mask or an occluder, and
1937
+ * `SetPartMesh` refuses to strip the mesh off one (both must be mesh parts).
1938
+ * No command creates or edits `clip` or `occlusion`.
1924
1939
  */
1925
1940
  declare function validateDeformerDelete(deformers: IkiDeformer[], parts: IkiPart[], physicsChains: IkiPhysicsChain[], deformerId: string): void;
1926
1941
  /**
package/dist/index.d.ts CHANGED
@@ -251,10 +251,11 @@ declare class AddDeformer implements EditCommand {
251
251
  * visually predictable.
252
252
  *
253
253
  * No `parseIkiModel` pre-check is needed: removing an element from an already-
254
- * valid model cannot introduce a structural violation — EXCEPT for clip-mask
255
- * references, the one part→part reference in the contract. `apply` refuses to
256
- * delete a part still used as another part's `clip.masks` entry (guard below),
257
- * so it can never leave a dangling mask ref that would fail `toIkiModel()`.
254
+ * valid model cannot introduce a structural violation — EXCEPT for the part→part
255
+ * references in the contract: `clip.masks` and `occlusion.by`. `apply` refuses to
256
+ * delete a part still used as another part's `clip.masks` or `occlusion.by`
257
+ * entry (guards below), so it can never leave a dangling ref that would fail
258
+ * `toIkiModel()`.
258
259
  *
259
260
  * Texture-reference safety (package invariant): `apply` refuses to delete a
260
261
  * part that still carries `part.texture`. Texture/atlas state is non-undoable
@@ -1081,6 +1082,19 @@ interface CustomPart {
1081
1082
  /** The art does not bend (a hairpin, a ribbon): a bake that bends it more
1082
1083
  * than 0.0025 face widths at a stop is refused (`BakeError`). */
1083
1084
  rigid?: boolean;
1085
+ /** Hide the part where the parts of these roles cover it, by an amount keyed on a
1086
+ * parameter (`IkiPart.occlusion`). `by` lists roles of the set: the
1087
+ * generator writes the part ids those roles yield. The parameter is one
1088
+ * the model declares and not a physics output; the stops follow the
1089
+ * format's rules. */
1090
+ occlusion?: {
1091
+ by: string[];
1092
+ parameter: string;
1093
+ stops: {
1094
+ value: number;
1095
+ amount: number;
1096
+ }[];
1097
+ };
1084
1098
  }
1085
1099
  /** A `customParts` entry that is malformed or misses the layer set. Its
1086
1100
  * message starts `customParts[i].<field>` (`customParts` alone for a
@@ -1917,10 +1931,11 @@ declare function validateDeformerReparent(deformers: IkiDeformer[], deformerId:
1917
1931
  * `toIkiModel()` refuses.
1918
1932
  *
1919
1933
  * Note: there is no validatePartDelete here, but NOT because parts are
1920
- * unreferenced — `clip.masks` names parts by id. That invariant is enforced
1921
- * closer to the edits that could break it: `DeletePart` refuses to remove a
1922
- * part still used as a mask, and `SetPartMesh` refuses to strip the mesh off
1923
- * one (masks must be mesh parts). No command creates or edits `clip`.
1934
+ * unreferenced — `clip.masks` and `occlusion.by` name parts by id. That
1935
+ * invariant is enforced closer to the edits that could break it: `DeletePart`
1936
+ * refuses to remove a part still used as a mask or an occluder, and
1937
+ * `SetPartMesh` refuses to strip the mesh off one (both must be mesh parts).
1938
+ * No command creates or edits `clip` or `occlusion`.
1924
1939
  */
1925
1940
  declare function validateDeformerDelete(deformers: IkiDeformer[], parts: IkiPart[], physicsChains: IkiPhysicsChain[], deformerId: string): void;
1926
1941
  /**
package/dist/index.js CHANGED
@@ -1115,6 +1115,14 @@ var DeletePart = class {
1115
1115
  `parts."${this.partId}": cannot delete \u2014 used as a clip mask by part "${masker.id}"; remove its clip first`
1116
1116
  );
1117
1117
  }
1118
+ const occluded = parts.find(
1119
+ (p) => p.id !== this.partId && p.occlusion?.by.includes(this.partId)
1120
+ );
1121
+ if (occluded) {
1122
+ throw new Error(
1123
+ `parts."${this.partId}": cannot delete \u2014 used as an occluder by part "${occluded.id}"; remove its occlusion first`
1124
+ );
1125
+ }
1118
1126
  const i = parts.indexOf(part);
1119
1127
  if (!this.captured) {
1120
1128
  this.removed = structuredClone(part);
@@ -1319,6 +1327,14 @@ var SetPartMesh = class {
1319
1327
  `parts."${this.partId}": cannot remove mesh \u2014 used as a clip mask by part "${masker.id}" (masks require a mesh); remove its clip first`
1320
1328
  );
1321
1329
  }
1330
+ const occluded = doc.getModel().parts.find(
1331
+ (p) => p.id !== this.partId && p.occlusion?.by.includes(this.partId)
1332
+ );
1333
+ if (occluded) {
1334
+ throw new import_format2.IkiFormatError(
1335
+ `parts."${this.partId}": cannot remove mesh \u2014 used as an occluder by part "${occluded.id}" (occluders require a mesh); remove its occlusion first`
1336
+ );
1337
+ }
1322
1338
  } else {
1323
1339
  if ((part.warps?.length ?? 0) > 0) {
1324
1340
  throw new import_format2.IkiFormatError(
@@ -1398,6 +1414,14 @@ function validatePhysicsCandidate(doc, candidatePhysics) {
1398
1414
  }
1399
1415
  throw e;
1400
1416
  }
1417
+ for (const rig of candidatePhysics) {
1418
+ const part = doc.getModel().parts.find((p) => p.occlusion?.parameter === rig.output.parameter);
1419
+ if (part !== void 0) {
1420
+ throw new import_format2.IkiFormatError(
1421
+ `physics."${rig.id}".output.parameter "${rig.output.parameter}" keys parts."${part.id}".occlusion; an occlusion amount cannot be keyed on a physics output`
1422
+ );
1423
+ }
1424
+ }
1401
1425
  }
1402
1426
  var AddPhysicsRig = class {
1403
1427
  label = "Add physics rig";
@@ -7132,7 +7156,18 @@ function span2(name, v) {
7132
7156
  }
7133
7157
 
7134
7158
  // src/auto-rig/custom-parts.ts
7135
- var KEYS = ["id", "layer", "anchor", "tie", "after", "deformer", "rigid"];
7159
+ var KEYS = [
7160
+ "id",
7161
+ "layer",
7162
+ "anchor",
7163
+ "tie",
7164
+ "after",
7165
+ "deformer",
7166
+ "rigid",
7167
+ "occlusion"
7168
+ ];
7169
+ var OCCLUSION_KEYS = ["by", "parameter", "stops"];
7170
+ var STOP_KEYS = ["value", "amount"];
7136
7171
  var LAYER_KEYS = ["fileName", "bbox"];
7137
7172
  var finite4 = (v) => typeof v === "number" && Number.isFinite(v);
7138
7173
  var isObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -7140,7 +7175,7 @@ var inside = (b, [x, y]) => x >= b.x && x < b.x + b.w && y >= b.y && y < b.y + b
7140
7175
  var RESERVED = new Set(
7141
7176
  ROLE_TABLE.flatMap((r) => [r.role, ...partIdsOfRole(r.role)])
7142
7177
  );
7143
- function checkCustomParts(customParts, byRole, canvas) {
7178
+ function checkCustomParts(customParts, byRole, canvas, parameters, physicsOutputs) {
7144
7179
  if (!Array.isArray(customParts)) {
7145
7180
  throw new CustomPartError(
7146
7181
  `customParts must be an array, got ${customParts === null ? "null" : typeof customParts}`
@@ -7163,7 +7198,7 @@ function checkCustomParts(customParts, byRole, canvas) {
7163
7198
  throw new CustomPartError(`${at} must be a plain object`);
7164
7199
  }
7165
7200
  checkedKeys(at, entry, KEYS, CustomPartError);
7166
- const { id, layer, anchor, tie, after, deformer, rigid } = entry;
7201
+ const { id, layer, anchor, tie, after, deformer, rigid, occlusion } = entry;
7167
7202
  if (typeof id !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(id)) {
7168
7203
  throw new CustomPartError(
7169
7204
  `${at}.id must be a plain identifier (letters, digits, underscore; not starting with a digit), got ${JSON.stringify(id)}`
@@ -7214,9 +7249,84 @@ function checkCustomParts(customParts, byRole, canvas) {
7214
7249
  `${at}.rigid must be true or false, got ${JSON.stringify(rigid)}`
7215
7250
  );
7216
7251
  }
7252
+ if (occlusion !== void 0) {
7253
+ checkOcclusion(
7254
+ `${at}.occlusion`,
7255
+ occlusion,
7256
+ present,
7257
+ parameters,
7258
+ physicsOutputs
7259
+ );
7260
+ }
7217
7261
  });
7218
7262
  return customParts;
7219
7263
  }
7264
+ function checkOcclusion(at, occlusion, present, parameters, physicsOutputs) {
7265
+ if (!isObject3(occlusion)) {
7266
+ throw new CustomPartError(`${at} must be a plain object`);
7267
+ }
7268
+ checkedKeys(at, occlusion, OCCLUSION_KEYS, CustomPartError);
7269
+ const { by, parameter, stops } = occlusion;
7270
+ if (!Array.isArray(by) || by.length === 0) {
7271
+ throw new CustomPartError(
7272
+ `${at}.by must be a non-empty array of roles of the set, one of ${present.join(", ")}`
7273
+ );
7274
+ }
7275
+ by.forEach((role, j) => {
7276
+ if (typeof role !== "string" || !present.includes(role)) {
7277
+ throw new CustomPartError(
7278
+ `${at}.by[${j}] must be a role of the set, one of ${present.join(", ")}; got ${JSON.stringify(role)}`
7279
+ );
7280
+ }
7281
+ if (by.indexOf(role) !== j) {
7282
+ throw new CustomPartError(
7283
+ `${at}.by[${j}] "${role}" repeats an earlier role`
7284
+ );
7285
+ }
7286
+ });
7287
+ const descriptor = parameters.find((p) => p.id === parameter);
7288
+ if (typeof parameter !== "string" || descriptor === void 0) {
7289
+ throw new CustomPartError(
7290
+ `${at}.parameter must be a parameter the rig declares, got ${JSON.stringify(parameter)}`
7291
+ );
7292
+ }
7293
+ if (physicsOutputs.has(parameter)) {
7294
+ throw new CustomPartError(
7295
+ `${at}.parameter "${parameter}" is a physics output; an occlusion amount cannot be keyed on one`
7296
+ );
7297
+ }
7298
+ if (!Array.isArray(stops) || stops.length === 0) {
7299
+ throw new CustomPartError(`${at}.stops must be a non-empty array`);
7300
+ }
7301
+ const { min, max } = descriptor;
7302
+ let previous = -Infinity;
7303
+ stops.forEach((stop, k) => {
7304
+ const s = `${at}.stops[${k}]`;
7305
+ if (!isObject3(stop))
7306
+ throw new CustomPartError(`${s} must be a plain object`);
7307
+ checkedKeys(s, stop, STOP_KEYS, CustomPartError);
7308
+ const { value, amount } = stop;
7309
+ if (!finite4(value)) {
7310
+ throw new CustomPartError(
7311
+ `${s}.value must be a finite number, got ${JSON.stringify(value)}`
7312
+ );
7313
+ }
7314
+ if (value < min || value > max) {
7315
+ throw new CustomPartError(
7316
+ `${s}.value ${value} is outside parameter "${parameter}" range [${min},${max}]`
7317
+ );
7318
+ }
7319
+ if (value <= previous) {
7320
+ throw new CustomPartError(`${s}.value must be strictly ascending`);
7321
+ }
7322
+ previous = value;
7323
+ if (!finite4(amount) || amount < 0 || amount > 1) {
7324
+ throw new CustomPartError(
7325
+ `${s}.amount must be a number in 0..1, got ${JSON.stringify(amount)}`
7326
+ );
7327
+ }
7328
+ });
7329
+ }
7220
7330
  var CELL_PX2 = 20;
7221
7331
  function buildCustomParts(customParts, canvas) {
7222
7332
  return customParts.map(({ id, layer }) => {
@@ -7236,6 +7346,32 @@ function buildCustomParts(customParts, canvas) {
7236
7346
  };
7237
7347
  });
7238
7348
  }
7349
+ function writeOcclusion(customParts, built, parts) {
7350
+ const byId = new Map(parts.map((p) => [p.id, p]));
7351
+ customParts.forEach((entry, i) => {
7352
+ if (entry.occlusion === void 0) return;
7353
+ const { by, parameter, stops } = entry.occlusion;
7354
+ const ids = [];
7355
+ by.forEach((role, j) => {
7356
+ for (const id of partIdsOfRole(role)) {
7357
+ const part = byId.get(id);
7358
+ if (part === void 0) continue;
7359
+ const fault = part.clip !== void 0 ? { is: "is clipped", kind: "a clipped part" } : part.mesh === void 0 ? { is: "has no mesh", kind: "a part without a mesh" } : void 0;
7360
+ if (fault !== void 0) {
7361
+ throw new CustomPartError(
7362
+ `customParts[${i}].occlusion.by[${j}] "${role}" yields part "${id}", which ${fault.is}; ${fault.kind} cannot occlude`
7363
+ );
7364
+ }
7365
+ ids.push(id);
7366
+ }
7367
+ });
7368
+ built[i].occlusion = {
7369
+ by: ids,
7370
+ parameter,
7371
+ stops: stops.map((s) => ({ value: s.value, amount: s.amount }))
7372
+ };
7373
+ });
7374
+ }
7239
7375
 
7240
7376
  // src/auto-rig/generate.ts
7241
7377
  var MESH_CELLS = {
@@ -7278,8 +7414,15 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
7278
7414
  const hasNose = has("nose");
7279
7415
  const plateHalf = byRole.get("face").cropW / 2;
7280
7416
  checkHeadEdges(options.headEdges, byRole);
7281
- const entries = options.customParts !== void 0 && !(Array.isArray(options.customParts) && options.customParts.length === 0) ? checkCustomParts(options.customParts, byRole, canvas) : [];
7417
+ const physics = has("hair_front") ? hairPhysics(has("hair_back")) : void 0;
7282
7418
  const parameters = declareParameters(new Set(byRole.keys()));
7419
+ const entries = options.customParts !== void 0 && !(Array.isArray(options.customParts) && options.customParts.length === 0) ? checkCustomParts(
7420
+ options.customParts,
7421
+ byRole,
7422
+ canvas,
7423
+ parameters,
7424
+ new Set((physics ?? []).map((r) => r.output.parameter))
7425
+ ) : [];
7283
7426
  const bakes = options.bakes === void 0 ? void 0 : parseBakes(options.bakes, entries, parameters);
7284
7427
  checkStrandEdges(
7285
7428
  options.strandEdges,
@@ -7460,6 +7603,7 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
7460
7603
  parts.forEach((p, i) => {
7461
7604
  p.order = i;
7462
7605
  });
7606
+ writeOcclusion(entries, built, parts);
7463
7607
  if (bakes !== void 0) {
7464
7608
  bakeReports = applyBakes(bakes, built, entries, {
7465
7609
  canvas,
@@ -7476,7 +7620,7 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
7476
7620
  parameters,
7477
7621
  parts,
7478
7622
  deformers: deformerList,
7479
- ...has("hair_front") ? { physics: hairPhysics(has("hair_back")) } : {},
7623
+ ...physics !== void 0 ? { physics } : {},
7480
7624
  expressions: defaultExpressions(new Set(parameters.map((p) => p.id))),
7481
7625
  motions: defaultMotions(new Set(parameters.map((p) => p.id)))
7482
7626
  };