spine-rigc 1.9.2 → 1.9.3

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.
Files changed (47) hide show
  1. package/cli.ts +28 -4397
  2. package/docs/AUTHORING.md +1 -1
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a13.ts +3 -3
  5. package/src/assertions/bodies/a14.ts +3 -3
  6. package/src/assertions/bodies/a15.ts +5 -5
  7. package/src/assertions/bodies/a22.ts +3 -3
  8. package/src/assertions/facts/animated_bones.ts +4 -2
  9. package/src/assertions/facts/mesh_attachments.ts +33 -4
  10. package/src/assertions/model/animated_bones.ts +13 -5
  11. package/src/assertions/model/bone_timelines.ts +5 -3
  12. package/src/assertions/model/declared.ts +127 -0
  13. package/src/assertions/model/index.ts +63 -40
  14. package/src/assertions/model/mesh_attachments.ts +31 -2
  15. package/src/assertions/model/parse.ts +9 -1
  16. package/src/assertions/model/runtime_timelines.ts +78 -0
  17. package/src/assertions/model/slider_composition.ts +10 -8
  18. package/src/assertions/model/stepped_poses.ts +3 -2
  19. package/src/bonedist.ts +4 -5
  20. package/src/check.ts +3 -2
  21. package/src/checkpics.ts +1 -1
  22. package/src/cli/core_commands.ts +1532 -0
  23. package/src/cli/shared.ts +1935 -0
  24. package/src/cli/spine_commands.ts +1141 -0
  25. package/src/core/additive.ts +52 -62
  26. package/src/core/constraints.ts +26 -3
  27. package/src/core/constraints_physics.ts +11 -1
  28. package/src/core/constraints_slider.ts +136 -6
  29. package/src/core/index.ts +27 -3
  30. package/src/core/skins.ts +28 -3
  31. package/src/core/walk.ts +9 -36
  32. package/src/correspondence.ts +15 -0
  33. package/src/deformbuild.ts +60 -0
  34. package/src/deformmeasure.ts +20 -48
  35. package/src/deformreport.ts +2 -2
  36. package/src/framing.ts +1 -1
  37. package/src/pose.ts +1 -1
  38. package/src/preview.ts +1 -1
  39. package/src/region_joins.ts +54 -0
  40. package/src/render.ts +206 -2836
  41. package/src/render_core.ts +19 -36
  42. package/src/render_shared.ts +2887 -0
  43. package/src/slots.ts +1 -1
  44. package/src/spine_side.ts +123 -0
  45. package/src/validate.ts +11 -91
  46. package/src/assertions/facts/skin_meshes.ts +0 -49
  47. package/src/assertions/model/skin_meshes.ts +0 -90
package/src/core/walk.ts CHANGED
@@ -65,22 +65,17 @@
65
65
  * `CoreInputError` naming why, as there — a pose with a block missing is not
66
66
  * a pose whose values can be judged finite.
67
67
  *
68
- * ⛔ **And one construct this walk refuses that the raw entry does not**
69
- * (`sliderPhysicsWhy`): a slider whose animation keys a physics timeline.
70
- * Issue #1049. Of the selftest's builds of that class (a dial, or two
71
- * sliders, keying a physics constraint's `wind` or `gravity`), five walked off spine-core from the third
72
- * step on — `tip` at worldX 14.87 against 12.09 on the first, at 0.025 s —
73
- * while others of the same class walked exact. The raw entry's own non-looping walk reads the same
74
- * gap (and with the slider made non-additive, 14.78 against 12.09), so it is
75
- * the stepped walk's reading of a slider's physics keys, not the loop's.
76
- * Which of the class it reaches is not measured, so the walk refuses the
77
- * class by name rather than return a pose it cannot vouch for. On the same
78
- * five inputs, `./additive.ts` and spine-core's probe agree that each slider's
79
- * `wind` or `gravity` timeline accumulates when applied twice with `add` — so
80
- * the gap is downstream of that rule, in the stepped bones, not in it.
68
+ * 🔁 **A slider whose animation keys a physics timeline** was refused here
69
+ * by name until issue #1049 (`sliderPhysicsWhy`): six of the path-slider
70
+ * suite's distinct builds walked off spine-core from the third step, and the
71
+ * raw entry read the same gap. The core had not applied a slider's physics keys at all;
72
+ * it now does, under the step, by the rule `./constraints_slider.ts` *Its
73
+ * physics timelines* states and the core suite's `CO31` holds — so this walk
74
+ * poses the class as the raw entry does, and refuses nothing the raw entry
75
+ * does not.
81
76
  */
82
77
  import { setupPoseIn, walkIn, type RawBone, type RawDrawn, type WalkMode } from './raw.ts';
83
- import { activeBones, CoreInputError, constraintRecords, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
78
+ import { activeBones, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
84
79
  import { historyTaint } from './constraints.ts';
85
80
  import type { TimelinePlant } from './animation.ts';
86
81
 
@@ -100,20 +95,6 @@ export interface WalkPose {
100
95
  drawn: RawDrawn[];
101
96
  }
102
97
 
103
- /**
104
- * Why the looping walk refuses this document, or `null` (the header's last
105
- * ⛔, issue #1049): every slider whose animation keys a physics timeline,
106
- * named with it.
107
- */
108
- export function sliderPhysicsWhy(doc: CompiledDocument): string | null {
109
- const keyed = constraintRecords(doc).flatMap((r) => {
110
- if (r.kind !== 'slider') return [];
111
- const anim = doc.animations.find((a) => a.name === r.animation);
112
- return anim !== undefined && anim.constraints.physics > 0 ? [`slider "${r.name}" applies animation "${r.animation}", which keys physics timelines`] : [];
113
- });
114
- return keyed.length === 0 ? null : `${keyed.join('; ')} — under the stepped walk spine-core's bones leave the core's from the third step, on the raw entry's walk as on this one, and that reading is not measured (issue #1049)`;
115
- }
116
-
117
98
  /**
118
99
  * The bones and slots whose numbers the walk does not vouch for, because the
119
100
  * runtime's own value there is HISTORY (issue #979): a bone the view leaves
@@ -142,12 +123,6 @@ export function walkHistory(view: CompiledDocument): { bones: Map<string, string
142
123
  return { bones, slots: new Map(view.slots.filter((s) => bones.has(s.bone)).map((s) => [s.name, s.bone])) };
143
124
  }
144
125
 
145
- /** Refuse, by name, a document the looping walk cannot vouch for (`sliderPhysicsWhy`). */
146
- function refuseUnmeasured(doc: CompiledDocument): void {
147
- const why = sliderPhysicsWhy(doc);
148
- if (why !== null) throw new CoreInputError(`the looping walk leaves the bones out: ${why}`);
149
- }
150
-
151
126
  /** The looping walk's mode: the track loops, and a non-finite value stays in the pose. */
152
127
  const LOOPING: WalkMode = { loop: true, keep: true };
153
128
 
@@ -184,7 +159,6 @@ function narrow(p: { trackTime: number; animationTime: number; bones: RawBone[];
184
159
  * what A10 reads before any animation is set.
185
160
  */
186
161
  export function poseWalkSetup(doc: CompiledDocument, plant: CorePlant = {}, walkPlant: WalkPlant = {}): WalkPose {
187
- refuseUnmeasured(doc);
188
162
  return narrow(setupPoseIn(doc, plant, LOOPING), walkPlant.number);
189
163
  }
190
164
 
@@ -196,7 +170,6 @@ export function poseWalkSetup(doc: CompiledDocument, plant: CorePlant = {}, walk
196
170
  * refuses the call by name; a value that is not finite does not.
197
171
  */
198
172
  export function poseLoopingWalk(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant = {}, walkPlant: WalkPlant = {}): WalkPose[] {
199
- refuseUnmeasured(doc);
200
173
  const mode: WalkMode = { ...LOOPING, ...(walkPlant.time === undefined ? {} : { time: walkPlant.time }), ...(walkPlant.clock === undefined ? {} : { clock: walkPlant.clock }), ...(walkPlant.wrapResets === undefined ? {} : { wrapResets: walkPlant.wrapResets }) };
201
174
  return walkIn(doc, animation, steps, plant, 'setup', mode).map((p) => narrow(p, walkPlant.number));
202
175
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The two spellings of a bone correspondence — what `rigc bonedist --bones`
3
+ * and `rigc bench --bones` take — moved here unchanged from `./bonedist.ts`
4
+ * (issue #1052), which re-exports both.
5
+ *
6
+ * Their own module because the CLI's help states them (`--bones`'s flag row)
7
+ * and an entry that links nothing of the runtime prints that help, while
8
+ * `./bonedist.ts` poses both skeletons through spine-core by design.
9
+ */
10
+
11
+ /** The sidecar spec a correspondence file declares, and the report's own. */
12
+ export const BONEDIST_SPEC = 'rigc-bonedist/1';
13
+
14
+ /** What `--bones identity` is spelled as, where a file path would go. */
15
+ export const IDENTITY_CORRESPONDENCE = 'identity';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The deform survey of a build, through the reader and poser asked for —
3
+ * `explain`'s `DEFORM` block and `tools/survey_hashes.ts` (issues #969,
4
+ * #1019).
5
+ *
6
+ * Moved here unchanged from `./deformmeasure.ts` (issue #1052, step 4e of
7
+ * #380), which re-exports both names: the choice between the model document
8
+ * posed by the core and the Spine skeleton posed by spine-core names no
9
+ * runtime class, and `explain` makes it on a rigc build without touching the
10
+ * runtime — so an entry that links nothing of spine-core has to be able to
11
+ * load it. The spine-core half (`throughSpine`) is `./deformmeasure.ts`'s,
12
+ * reached through the seam (`./spine_side.ts`); where nothing registered it,
13
+ * a survey that needs it is refused by name (`SpineRuntimeError`).
14
+ */
15
+ import { CoreInputError, readModel } from './core/index.ts';
16
+ import { surveyOfModel, type DeformSurvey, type DeformSurveySource } from './deformsurvey.ts';
17
+ import { spineFileSha256 } from './model.ts';
18
+ import { spineSurveyFor } from './spine_side.ts';
19
+
20
+ /** A build's three texts, as `explain` and `tools/survey_hashes.ts` hold them. */
21
+ export interface DeformSurveyInput {
22
+ skeletonText: string;
23
+ atlasText: string;
24
+ /** The model document (`skeleton.model.json`), or `null` when the input carries none (a Spine export). */
25
+ modelText: string | null;
26
+ /** What a refusal names the survey's input as; `the deform survey` unstated. */
27
+ label?: string;
28
+ }
29
+
30
+ /**
31
+ * The survey of a build, through the reader and poser asked for (issues #969,
32
+ * #1019): `model` — the model document's structure posed by the core,
33
+ * refused when there is none or when the Spine file beside it is not the one
34
+ * it records; `spine-core` — the Spine skeleton read and posed by the runtime;
35
+ * `auto` — the model document when the input carries one and the core poses
36
+ * it, spine-core otherwise, the reason named in `source.why`. On the model
37
+ * path spine-core is not touched: the Spine text is hashed, never parsed.
38
+ */
39
+ export function surveyOfBuild(input: DeformSurveyInput, exempt: ReadonlySet<string>, asked: 'auto' | DeformSurveySource): DeformSurvey {
40
+ const label = input.label ?? 'the deform survey';
41
+ // `./deformmeasure.ts`'s reader and poser, through the seam: it touches the runtime first and refuses by name where it cannot be used.
42
+ const throughSpine = (why: string | null): DeformSurvey => spineSurveyFor(label, why ?? '--poser spine').throughSpine(label, why, input, exempt);
43
+ if (asked === 'spine-core') return throughSpine(null);
44
+ if (input.modelText === null) {
45
+ if (asked === 'model') throw new CoreInputError('the survey was asked to pose the model document, and the input carries none');
46
+ return throughSpine('the input carries no model document (skeleton.model.json), so the survey posed the Spine skeleton through spine-core');
47
+ }
48
+ try {
49
+ const doc = readModel(input.modelText);
50
+ // The document reads the rig it was built with; the Spine file beside it must be that build's (issue #968's rule).
51
+ const found = spineFileSha256(input.skeletonText);
52
+ if (found !== doc.spine.sha256) {
53
+ throw new CoreInputError(`the skeleton is not the one the model document was written beside: its sha256 is ${found}, the document records ${doc.spine.sha256 || 'none'}`);
54
+ }
55
+ return surveyOfModel(doc, exempt);
56
+ } catch (err) {
57
+ if (!(err instanceof CoreInputError) || asked === 'model') throw err;
58
+ return throughSpine(`the core refused to pose the model document — ${err.message}`);
59
+ }
60
+ }
@@ -124,7 +124,7 @@ import {
124
124
  type Timeline,
125
125
  } from '@esotericsoftware/spine-core';
126
126
  // #969: the core's side of the seam — the model document read.
127
- import { CoreInputError, readModel } from './core/index.ts';
127
+ import { readModel } from './core/index.ts';
128
128
  // #1019: what the survey reads of the skeleton's structure, and the model document's reading of it.
129
129
  import {
130
130
  modelStructure,
@@ -137,11 +137,15 @@ import {
137
137
  type SurveyStructure,
138
138
  } from './deformstructure.ts';
139
139
  // Issue #1025 (cut 4c-3): the half of the survey that names no runtime class.
140
- import { BEZIER_POINTS, corePoser, surveyOfModel, surveyWith, type DeformSurvey, type DeformSurveySource, type DialField, type ShownReading, type SurveyPose, type SurveyPoser } from './deformsurvey.ts';
141
- import { spineFileSha256 } from './model.ts';
142
- import { SpineRuntimeError } from './render.ts';
140
+ import { BEZIER_POINTS, corePoser, surveyWith, type DeformSurvey, type DialField, type ShownReading, type SurveyPose, type SurveyPoser } from './deformsurvey.ts';
141
+ // Issue #1052: the choice of reader and poser over a build is `./deformbuild.ts`'s, where an entry that links nothing of
142
+ // the runtime can load it; this file's half of it — the survey through spine-core — is registered into the seam below.
143
+ import type { DeformSurveyInput } from './deformbuild.ts';
144
+ import { registerSpineSurvey, SpineRuntimeError, spineRuntimeSentence, SURVEY_RUNTIME_TAIL } from './spine_side.ts';
143
145
 
144
146
  export { BEZIER_POINTS, DEFORM_AREA_EPSILON, float32AreaNoise, stretchSingularValues, surveyOfModel, triangleAreas, unreachableWhy } from './deformsurvey.ts';
147
+ export { surveyOfBuild } from './deformbuild.ts';
148
+ export type { DeformSurveyInput } from './deformbuild.ts';
145
149
  export type {
146
150
  DeformDial,
147
151
  DeformDialDispute,
@@ -694,16 +698,6 @@ export function deformPosers(input: DeformSurveyInput & { modelText: string }):
694
698
  return { data, structure: side.structure, model: modelStructure(doc), spine: side.poser, core: corePoser(side.structure, doc) };
695
699
  }
696
700
 
697
- /** A build's three texts, as `explain` and `tools/survey_hashes.ts` hold them. */
698
- export interface DeformSurveyInput {
699
- skeletonText: string;
700
- atlasText: string;
701
- /** The model document (`skeleton.model.json`), or `null` when the input carries none (a Spine export). */
702
- modelText: string | null;
703
- /** What a refusal names the survey's input as; `the deform survey` unstated. */
704
- label?: string;
705
- }
706
-
707
701
  /**
708
702
  * Touch the runtime once, before the skeleton is parsed through it, and refuse
709
703
  * by name when it cannot be used (issue #1019) — the sentence `render` and
@@ -716,45 +710,23 @@ function requireSpineRuntime(label: string, why: string): void {
716
710
  // A property read on the class the parse starts from: no runtime code runs, and a runtime that cannot be used throws here.
717
711
  void TextureAtlas.prototype;
718
712
  } catch (err) {
719
- throw new SpineRuntimeError(
720
- `${label} is posed through spine-core (${why}), and the runtime could not be used: ${(err as Error).message}. ` +
721
- 'spine-core is what reads and poses a Spine export, --poser spine and a fallback the survey names; ' +
722
- 'a rigc build the core poses reads nothing through it',
723
- );
713
+ // The sentence an entry that registered no Spine side is refused in too (`./spine_side.ts`), with the runtime's own words as the reason.
714
+ throw new SpineRuntimeError(spineRuntimeSentence(label, why, (err as Error).message, SURVEY_RUNTIME_TAIL));
724
715
  }
725
716
  }
726
717
 
727
718
  /**
728
- * The survey of a build, through the reader and poser asked for (issues #969,
729
- * #1019): `model` — the model document's structure posed by the core,
730
- * refused when there is none or when the Spine file beside it is not the one
731
- * it records; `spine-core` — the Spine skeleton read and posed by the runtime;
732
- * `auto` — the model document when the input carries one and the core poses
733
- * it, spine-core otherwise, the reason named in `source.why`. On the model
734
- * path spine-core is not touched: the Spine text is hashed, never parsed.
719
+ * The survey through spine-core — the half of `surveyOfBuild`
720
+ * (`./deformbuild.ts`, moved there unchanged in issue #1052) that names the
721
+ * runtime, registered into the seam when this file is loaded: it touches the
722
+ * runtime once (`requireSpineRuntime`), then reads and poses the Spine
723
+ * skeleton and names `why` as the survey's source. Every program that imports
724
+ * this file surveys as before; one that does not refuses this half by name.
735
725
  */
736
- export function surveyOfBuild(input: DeformSurveyInput, exempt: ReadonlySet<string>, asked: 'auto' | DeformSurveySource): DeformSurvey {
737
- const label = input.label ?? 'the deform survey';
738
- const throughSpine = (why: string | null): DeformSurvey => {
726
+ registerSpineSurvey({
727
+ throughSpine: (label, why, input, exempt) => {
739
728
  requireSpineRuntime(label, why ?? '--poser spine');
740
729
  const side = runtimeSide(skeletonDataFromText(input.skeletonText, input.atlasText));
741
730
  return { ...surveyWith(side.structure, exempt, side.poser), source: { used: 'spine-core', why } };
742
- };
743
- if (asked === 'spine-core') return throughSpine(null);
744
- if (input.modelText === null) {
745
- if (asked === 'model') throw new CoreInputError('the survey was asked to pose the model document, and the input carries none');
746
- return throughSpine('the input carries no model document (skeleton.model.json), so the survey posed the Spine skeleton through spine-core');
747
- }
748
- try {
749
- const doc = readModel(input.modelText);
750
- // The document reads the rig it was built with; the Spine file beside it must be that build's (issue #968's rule).
751
- const found = spineFileSha256(input.skeletonText);
752
- if (found !== doc.spine.sha256) {
753
- throw new CoreInputError(`the skeleton is not the one the model document was written beside: its sha256 is ${found}, the document records ${doc.spine.sha256 || 'none'}`);
754
- }
755
- return surveyOfModel(doc, exempt);
756
- } catch (err) {
757
- if (!(err instanceof CoreInputError) || asked === 'model') throw err;
758
- return throughSpine(`the core refused to pose the model document — ${err.message}`);
759
- }
760
- }
731
+ },
732
+ });
@@ -7,8 +7,8 @@
7
7
  * block is a report and not an assertion, and what it deliberately leaves out.
8
8
  */
9
9
  import type { CompileResult } from './types.ts';
10
- import type { DeformExtreme, DeformKeyMeasure, DeformSpan, DeformSurvey } from './deformmeasure.ts';
11
- import { unreachableWhy } from './deformmeasure.ts';
10
+ import type { DeformExtreme, DeformKeyMeasure, DeformSpan, DeformSurvey } from './deformsurvey.ts';
11
+ import { unreachableWhy } from './deformsurvey.ts';
12
12
  import { float32Step } from './timelines.ts';
13
13
 
14
14
  /**
package/src/framing.ts CHANGED
@@ -55,7 +55,7 @@ import {
55
55
  viewportOfSize,
56
56
  type Frame,
57
57
  type Viewport,
58
- } from './render.ts';
58
+ } from './render_shared.ts';
59
59
 
60
60
  /** How far a channel must move for a pixel to count as "not background". */
61
61
  export const BACKGROUND_TOLERANCE = 8;
package/src/pose.ts CHANGED
@@ -80,7 +80,7 @@ import { Plate, readPlate } from '../tools/plate.ts';
80
80
  // straddles an edge. #301 deliberately left this call on the old arithmetic to
81
81
  // hold the fitting numbers still while the from-zero run 2 was in flight; #306
82
82
  // moved it, with the re-baseline of every figure that quoted a residual.
83
- import { bilinear } from './render.ts';
83
+ import { bilinear } from './render_shared.ts';
84
84
 
85
85
  export class PoseError extends Error {}
86
86
 
package/src/preview.ts CHANGED
@@ -45,7 +45,7 @@
45
45
  * loud when the script does not arrive.
46
46
  */
47
47
  import { SPINE_VERSION } from './compile.ts';
48
- import { BACKGROUND } from './render.ts';
48
+ import { BACKGROUND } from './render_shared.ts';
49
49
 
50
50
  /**
51
51
  * The Spine Web Player line the generated page loads.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every atlas-region lookup a skeleton's attachments ask for, read off its raw
3
+ * JSON (issue #589) — moved here unchanged from `./validate.ts` (issue #1052),
4
+ * which re-exports it.
5
+ *
6
+ * Its own module because two readers need it and only one of them links the
7
+ * runtime: `A08`'s runtime supplier in `./validate.ts`, and `explain`, which
8
+ * refuses art it cannot pose (`refuseUnposableArt`) and runs in an entry that
9
+ * links nothing of spine-core. The walk names no runtime class — it reads the
10
+ * file, and is measured against the runtime's loader by `PS127`.
11
+ */
12
+ import { attachmentRegionLookups, type AttachmentRegionJoin } from './assertions/region_lookups.ts';
13
+ import { isObj } from './assertions/values.ts';
14
+
15
+ /**
16
+ * Every atlas-region lookup `AtlasAttachmentLoader` will perform, read off the
17
+ * RAW skeleton JSON — before the loader is asked, which is the whole point.
18
+ *
19
+ * 🚨 This is a SECOND implementation of a join `spine-core` already performs,
20
+ * and the tree's standing judgment about a second opinion on somebody else's
21
+ * format is that it is measured rather than asserted: `PS127` runs the loader
22
+ * with its `findRegion` recording what it asked for, and compares. A wrong walk
23
+ * here would refuse correct foreign data by name, which is the one failure that
24
+ * would be worse than the silence #589 removed.
25
+ *
26
+ * Which entries resolve a region is the parser's list, not a guess:
27
+ * `SkeletonJson.readAttachment` (`dist/SkeletonJson.js:524-575`) calls the
28
+ * loader with a path for `region`, `mesh` and `linkedmesh` — a linked mesh
29
+ * resolves its own region before the `source` branch — and for nothing else.
30
+ * `type` defaults to `region` (`:527`), `name` to the placeholder (`:526`) and
31
+ * `path` to the name (`:529`, `:560`): three names that default into one
32
+ * another, which is why a report printing only the last of them cannot say
33
+ * what to change.
34
+ */
35
+ export function attachmentRegionJoins(raw: unknown): AttachmentRegionJoin[] {
36
+ const joins: AttachmentRegionJoin[] = [];
37
+ if (!isObj(raw) || !Array.isArray(raw.skins)) return joins;
38
+ for (const skin of raw.skins as unknown[]) {
39
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
40
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
41
+ for (const [slot, entries] of Object.entries(skin.attachments)) {
42
+ if (!isObj(entries)) continue;
43
+ for (const [placeholder, entry] of Object.entries(entries)) {
44
+ if (!isObj(entry)) continue;
45
+ const type = entry.type === undefined ? 'region' : entry.type;
46
+ if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
47
+ const name = typeof entry.name === 'string' ? entry.name : placeholder;
48
+ const path = typeof entry.path === 'string' ? entry.path : name;
49
+ joins.push({ skin: skinName, slot, placeholder, name, lookups: attachmentRegionLookups(entry.sequence, path) });
50
+ }
51
+ }
52
+ }
53
+ return joins;
54
+ }