@things-factory/figure-ui 10.1.72 → 10.1.74
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/client/modeller/figure-v3-links.ts +12 -5
- package/client/modeller/figure-v3-parts.ts +1 -0
- package/client/modeller/figure-v3-properties.ts +3 -2
- package/client/modeller/v3-editor.ts +86 -2
- package/client/modeller/v3-figure-facts.ts +26 -2
- package/client/modeller/v3-links.ts +50 -0
- package/client/modeller/v3-recipes.ts +16 -1
- package/dist-client/modeller/figure-v3-links.js +9 -3
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.js +1 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.js +3 -2
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +9 -0
- package/dist-client/modeller/v3-editor.js +88 -1
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-figure-facts.d.ts +9 -0
- package/dist-client/modeller/v3-figure-facts.js +23 -2
- package/dist-client/modeller/v3-figure-facts.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +17 -0
- package/dist-client/modeller/v3-links.js +38 -0
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-recipes.js +21 -1
- package/dist-client/modeller/v3-recipes.js.map +1 -1
- package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/modeller-page.js +40 -1
- package/test/browser/modeller.check.mjs +65 -3
- package/test/v3-fields.test.ts +2 -1
- package/test/v3-links.test.ts +25 -0
- package/test/v3-properties.test.ts +13 -0
- package/test/v3-recipes.test.ts +7 -0
- package/translations/en.json +11 -2
- package/translations/ja.json +10 -1
- package/translations/ko.json +11 -2
- package/translations/ms.json +10 -1
- package/translations/zh.json +10 -1
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
shownWhenOf,
|
|
28
28
|
visibilityControlsOf,
|
|
29
29
|
placeKeptOf,
|
|
30
|
+
fasteningOf,
|
|
30
31
|
hasInstanceSize,
|
|
31
32
|
holdsThings,
|
|
32
33
|
leadersFor,
|
|
@@ -49,7 +50,7 @@ import { HelpToggles } from './v3-help.js'
|
|
|
49
50
|
const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
50
51
|
|
|
51
52
|
const freshFasten = () => ({
|
|
52
|
-
face: 'bottom' as V3Face,
|
|
53
|
+
face: 'bottom' as V3Face | 'centre',
|
|
53
54
|
toPlane: false,
|
|
54
55
|
target: '',
|
|
55
56
|
targetFace: 'top' as V3Face,
|
|
@@ -131,7 +132,9 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
131
132
|
*/
|
|
132
133
|
willUpdate(changed: Map<string, unknown>) {
|
|
133
134
|
if (changed.has('partId') && changed.get('partId') !== undefined) {
|
|
134
|
-
|
|
135
|
+
/* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
|
|
136
|
+
const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null
|
|
137
|
+
this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
|
|
135
138
|
this.motion = freshMotion()
|
|
136
139
|
this.place = freshPlace()
|
|
137
140
|
this.row = freshRow()
|
|
@@ -355,8 +358,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
355
358
|
/* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
|
|
356
359
|
/* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
|
|
357
360
|
const plane = mountingPlaneOf(asset)
|
|
358
|
-
|
|
359
|
-
const
|
|
361
|
+
/* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
|
|
362
|
+
const mineFaces: Array<V3Face | 'centre'> = [
|
|
363
|
+
...(facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face) && (!f.toPlane || plane.faces.includes(face as V3Face))) as V3Face[]),
|
|
364
|
+
...(f.toPlane ? [] : (['centre'] as const))
|
|
365
|
+
]
|
|
366
|
+
const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0]!
|
|
360
367
|
const theirFaces = f.target ? facesOf(asset, f.target) : FACES
|
|
361
368
|
const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
|
|
362
369
|
const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
|
|
@@ -372,7 +379,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
372
379
|
: nothing}
|
|
373
380
|
<div form>
|
|
374
381
|
<span>${i18next.t('figure.label.my-face')}</span>
|
|
375
|
-
<select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
382
|
+
<select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face | 'centre' })}>
|
|
376
383
|
${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
377
384
|
</select>
|
|
378
385
|
</div>
|
|
@@ -463,6 +463,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
463
463
|
>
|
|
464
464
|
<b>${b.grade}</b>
|
|
465
465
|
<span>
|
|
466
|
+
<strong performance-cost>${i18next.t('figure.label.performance-cost')}</strong>
|
|
466
467
|
${i18next.t('figure.text.parts-budget', {
|
|
467
468
|
triangles: b.triangles.toLocaleString(),
|
|
468
469
|
groups: b.groups.toLocaleString(),
|
|
@@ -161,8 +161,9 @@ export class FigureV3Properties extends localize(i18next)(LitElement) {
|
|
|
161
161
|
|
|
162
162
|
private renderMounting(facts: V3FigureFacts) {
|
|
163
163
|
const mounting = facts.mounting
|
|
164
|
-
|
|
165
|
-
const
|
|
164
|
+
/* Chosen, or what is set, or where the figure meets its plane -- not the first part listed (walk 1). */
|
|
165
|
+
const part = [this.pickedPart, mounting?.part, facts.suggested?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
|
|
166
|
+
const face: V3Face = this.pickedFace ?? mounting?.face ?? facts.suggested?.face ?? 'bottom'
|
|
166
167
|
return html`
|
|
167
168
|
<div field>
|
|
168
169
|
<label>${i18next.t('figure.label.mounting-face')}</label>
|
|
@@ -7,7 +7,7 @@ import { live } from 'lit/directives/live.js'
|
|
|
7
7
|
import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model'
|
|
8
8
|
import type { RecipeTemplate } from './v3-recipes.js'
|
|
9
9
|
import './figure-v3-recipe-catalog.js'
|
|
10
|
-
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
|
|
10
|
+
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3ProposalStep, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
|
|
11
11
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
12
12
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
13
13
|
import { drawableV3Parts } from './v3-renderer.js'
|
|
@@ -269,6 +269,8 @@ export class FigureV3Editor extends LitElement {
|
|
|
269
269
|
/** Reads a template's guide; given by the page. */
|
|
270
270
|
@property({ attribute: false }) guideOf?: (name: string) => Promise<string>
|
|
271
271
|
@property({ attribute: false }) thumbnailOf?: (name: string) => string
|
|
272
|
+
/** The 「보드에서 바꿀 수 있음」 form being filled for a row, before it is published (ADR-0096). */
|
|
273
|
+
@state() private publishing?: { part: string; label: string; min: number; max: number }
|
|
272
274
|
@state() private catalogOpen = false
|
|
273
275
|
/** Why the last recipe was refused, in the kernel's words. */
|
|
274
276
|
@state() private recipeError = ''
|
|
@@ -1040,6 +1042,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
1040
1042
|
})
|
|
1041
1043
|
: i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
|
|
1042
1044
|
</p>
|
|
1045
|
+
${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
|
|
1043
1046
|
${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
|
|
1044
1047
|
? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
|
|
1045
1048
|
<md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
|
|
@@ -1047,6 +1050,80 @@ export class FigureV3Editor extends LitElement {
|
|
|
1047
1050
|
: nothing}
|
|
1048
1051
|
</section>`
|
|
1049
1052
|
}
|
|
1053
|
+
/** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
|
|
1054
|
+
private pitchInputOf(asset: V3Asset, part: string): string | undefined {
|
|
1055
|
+
const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch as { from?: { of?: string; input?: string } } | number | undefined
|
|
1056
|
+
return typeof pitch === 'object' && pitch?.from?.of === 'input' ? pitch.from.input : undefined
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* 「보드에서 바꿀 수 있음」 (ADR-0096): the row's pitch made a design input the figure publishes -- a name on the board and
|
|
1060
|
+
* a range the board's value is held to -- in one step; changed or withdrawn the same way. Only a row with a record.
|
|
1061
|
+
*/
|
|
1062
|
+
private renderPublicPitch(asset: V3Asset, pitch: number, unavailable: boolean) {
|
|
1063
|
+
const part = this.selected
|
|
1064
|
+
if (!asset.joints?.some(j => j.body1.part === part && j.repeat)) return nothing
|
|
1065
|
+
const from = this.pitchInputOf(asset, part)
|
|
1066
|
+
const listed = from ? asset.publicInputs?.find(p => p.input === from) : undefined
|
|
1067
|
+
const input = from ? asset.document.model.inputs.find(i => i.id === from) : undefined
|
|
1068
|
+
const form =
|
|
1069
|
+
this.publishing?.part === part
|
|
1070
|
+
? this.publishing
|
|
1071
|
+
: listed && input
|
|
1072
|
+
? { part, label: listed.label, min: input.min, max: input.max }
|
|
1073
|
+
: undefined
|
|
1074
|
+
const open = !!form
|
|
1075
|
+
const edit = (key: 'label' | 'min' | 'max') => (e: Event) => {
|
|
1076
|
+
const target = e.target as HTMLInputElement
|
|
1077
|
+
const base = form ?? { part, label: '', min: 0, max: 0 }
|
|
1078
|
+
this.publishing = { ...base, [key]: key === 'label' ? target.value : target.valueAsNumber }
|
|
1079
|
+
}
|
|
1080
|
+
const steps = (): V3ProposalStep[] => {
|
|
1081
|
+
const f = form!
|
|
1082
|
+
const id = from ?? `${part}.pitch`
|
|
1083
|
+
if (listed) return [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }, { kind: 'publish-input', input: id, label: f.label }] as V3ProposalStep[]
|
|
1084
|
+
const exists = asset.document.model.inputs.some(i => i.id === id)
|
|
1085
|
+
return [
|
|
1086
|
+
...(exists ? [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }] : [{ kind: 'add-design-input', id, unit: 'mm', min: f.min, max: f.max, value: pitch }]),
|
|
1087
|
+
...(from ? [] : [{ kind: 'set-repeat', part, pitch: { from: { of: 'input', input: id } } }]),
|
|
1088
|
+
{ kind: 'publish-input', input: id, label: f.label }
|
|
1089
|
+
] as V3ProposalStep[]
|
|
1090
|
+
}
|
|
1091
|
+
const run = (list: V3ProposalStep[]) => {
|
|
1092
|
+
try {
|
|
1093
|
+
this.session!.applyRecipe(list)
|
|
1094
|
+
this.publishing = undefined
|
|
1095
|
+
this.error = ''
|
|
1096
|
+
this.changed()
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
this.error = error instanceof Error ? error.message : String(error)
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return html`<div public-pitch>
|
|
1102
|
+
<label toggle>
|
|
1103
|
+
<input
|
|
1104
|
+
type="checkbox"
|
|
1105
|
+
aria-label="figure.label.board-sets"
|
|
1106
|
+
.checked=${open}
|
|
1107
|
+
?disabled=${unavailable}
|
|
1108
|
+
@change=${(e: Event) =>
|
|
1109
|
+
(e.target as HTMLInputElement).checked
|
|
1110
|
+
? (this.publishing = { part, label: i18next.t('figure.label.repeat-gap'), min: Math.max(1, Math.round(pitch / 2)), max: Math.round(pitch * 2) })
|
|
1111
|
+
: listed
|
|
1112
|
+
? run([{ kind: 'unpublish-input', input: from }, { kind: 'set-repeat', part, pitch }] as V3ProposalStep[])
|
|
1113
|
+
: (this.publishing = undefined)}
|
|
1114
|
+
/>
|
|
1115
|
+
${i18next.t('figure.label.board-sets')}
|
|
1116
|
+
</label>
|
|
1117
|
+
${open
|
|
1118
|
+
? html`<div inline-field><label>${i18next.t('figure.label.board-name')}</label><input aria-label="figure.label.board-name" .value=${form!.label} @change=${edit('label')} /></div>
|
|
1119
|
+
<div inline-field><label>${i18next.t('figure.label.least')} <span unit>mm</span></label><input type="number" step="any" aria-label="figure.label.least" .value=${String(form!.min)} @change=${edit('min')} /></div>
|
|
1120
|
+
<div inline-field><label>${i18next.t('figure.label.most')} <span unit>mm</span></label><input type="number" step="any" aria-label="figure.label.most" .value=${String(form!.max)} @change=${edit('max')} /></div>
|
|
1121
|
+
<button act publish-pitch ?disabled=${unavailable || !form!.label.trim()} @click=${() => run(steps())}>
|
|
1122
|
+
<md-icon>${listed ? 'check' : 'ios_share'}</md-icon>${i18next.t(listed ? 'figure.button.apply' : 'figure.button.publish-input')}
|
|
1123
|
+
</button>`
|
|
1124
|
+
: nothing}
|
|
1125
|
+
</div>`
|
|
1126
|
+
}
|
|
1050
1127
|
/**
|
|
1051
1128
|
* A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
|
|
1052
1129
|
* which a plain input edit would not. Refused only when the authored length would already hold more copies than
|
|
@@ -1056,6 +1133,13 @@ export class FigureV3Editor extends LitElement {
|
|
|
1056
1133
|
const input = event.target as HTMLInputElement
|
|
1057
1134
|
const pitch = input.valueAsNumber
|
|
1058
1135
|
if (!Number.isFinite(pitch) || pitch === field.value) return
|
|
1136
|
+
/* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
|
|
1137
|
+
const from = this.pitchInputOf(JSON.parse(this.session!.source()) as V3Asset, this.selected)
|
|
1138
|
+
if (from) {
|
|
1139
|
+
this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
|
|
1140
|
+
input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
|
|
1141
|
+
return
|
|
1142
|
+
}
|
|
1059
1143
|
try {
|
|
1060
1144
|
this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
|
|
1061
1145
|
this.error = ''
|
|
@@ -1228,7 +1312,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
1228
1312
|
name-field
|
|
1229
1313
|
aria-label=${i18next.t('figure.label.name')}
|
|
1230
1314
|
.value=${partLabelOf(this.selected)}
|
|
1231
|
-
?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
|
|
1315
|
+
?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)?.index}
|
|
1232
1316
|
@change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
|
|
1233
1317
|
/>
|
|
1234
1318
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseV3Asset } from '@hatiolab/figure-model'
|
|
1
|
+
import { compileV3Asset, parseV3Asset, v3OccupiedBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { V3Asset, V3Face } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
/*
|
|
@@ -14,6 +14,12 @@ export interface V3FigureFacts {
|
|
|
14
14
|
declared: boolean
|
|
15
15
|
placement?: Placement
|
|
16
16
|
mounting?: { part: string; face: V3Face }
|
|
17
|
+
/**
|
|
18
|
+
* Where the figure meets its plane, as its parts stand: the part whose bottom is lowest for a floor, whose top is
|
|
19
|
+
* highest for a ceiling, whose back is furthest back for a wall. What the mounting face is offered as before one is
|
|
20
|
+
* chosen -- the first part in the list offered a conveyor's bed, 700 mm up on its legs (walk 1, 2026-09-26).
|
|
21
|
+
*/
|
|
22
|
+
suggested?: { part: string; face: V3Face }
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -33,10 +39,28 @@ export function v3FigureFacts(source: string): V3FigureFacts {
|
|
|
33
39
|
parts,
|
|
34
40
|
declared: !!asset.occupancy,
|
|
35
41
|
placement: asset.occupancy?.placement as Placement | undefined,
|
|
36
|
-
mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined
|
|
42
|
+
mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined,
|
|
43
|
+
suggested: suggestedMountOf(asset, parts)
|
|
37
44
|
}
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
function suggestedMountOf(asset: V3Asset, parts: string[]): { part: string; face: V3Face } | undefined {
|
|
48
|
+
const placement = (asset.occupancy?.placement ?? 'floor') as Placement
|
|
49
|
+
if (placement === 'center' || !parts.length) return undefined
|
|
50
|
+
let boxes
|
|
51
|
+
try {
|
|
52
|
+
boxes = v3OccupiedBoxesOf(compileV3Asset(asset).evaluate().geometry, v3WritersOf(asset)).filter(b => parts.includes(b.part))
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
const [axis, face, sign] = placement === 'wall' ? (['z', 'back', -1] as const) : placement === 'ceiling' ? (['y', 'top', 1] as const) : (['y', 'bottom', -1] as const)
|
|
57
|
+
/* The side of each box that faces the plane; the one furthest toward it, the first in the list among equals. */
|
|
58
|
+
const reach = (b: (typeof boxes)[number]) => sign * (b.centre[axis] + (sign * b.extent[axis]) / 2)
|
|
59
|
+
let best: (typeof boxes)[number] | undefined
|
|
60
|
+
for (const b of boxes) if (!best || reach(b) > reach(best) + 1e-6) best = b
|
|
61
|
+
return best ? { part: best.part, face } : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/**
|
|
41
65
|
* A tag line as a list: split on commas, drop a leading `#`, keep each once. An empty
|
|
42
66
|
* line is an empty list, so an author can clear every tag.
|
|
@@ -182,6 +182,56 @@ export function hangsFrom(asset: V3Asset, part: string, from: string): boolean {
|
|
|
182
182
|
return false
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The fastening a part has, as the fastening form holds it -- so the form opens on what is there (the face, including
|
|
187
|
+
* \`centre\`, the target and its face, the gap, the twist and where across the face) and a change is a change to that.
|
|
188
|
+
* Opening it on the defaults showed a roller fastened by its centre as fastened by its bottom, and fastening again
|
|
189
|
+
* from there broke its turning in place (walk 1, 2026-09-26). Null for a part fastened to nothing.
|
|
190
|
+
*/
|
|
191
|
+
export function fasteningOf(
|
|
192
|
+
asset: V3Asset,
|
|
193
|
+
part: string
|
|
194
|
+
): {
|
|
195
|
+
face: string
|
|
196
|
+
toPlane: boolean
|
|
197
|
+
target: string
|
|
198
|
+
targetFace: string
|
|
199
|
+
facing: 'meet' | 'flush'
|
|
200
|
+
gap: number
|
|
201
|
+
twist: number
|
|
202
|
+
align: Partial<Record<Axis, Alignment>>
|
|
203
|
+
alignMm: Partial<Record<Axis, number>>
|
|
204
|
+
} | null {
|
|
205
|
+
const record = asset.joints?.find(j => j.body1.part === part)
|
|
206
|
+
if (!record) return null
|
|
207
|
+
const toPlane = 'plane' in record.body0
|
|
208
|
+
const target = 'part' in record.body0 ? record.body0.part : ''
|
|
209
|
+
const targetFace = 'part' in record.body0 ? record.body0.face : 'top'
|
|
210
|
+
const twist = record.origin?.twist ?? 0
|
|
211
|
+
/* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
|
|
212
|
+
const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
|
|
213
|
+
for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
|
|
214
|
+
if (how === 'min' || how === 'max' || how === 'centre') {
|
|
215
|
+
kept.align[axis] = how
|
|
216
|
+
delete kept.alignMm[axis]
|
|
217
|
+
} else if (how && typeof how === 'object' && typeof (how as { mm?: unknown }).mm === 'number') {
|
|
218
|
+
kept.align[axis] = 'mm'
|
|
219
|
+
kept.alignMm[axis] = (how as { mm: number }).mm
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const gap = record.origin?.gap
|
|
223
|
+
return {
|
|
224
|
+
face: record.body1.face,
|
|
225
|
+
toPlane,
|
|
226
|
+
target,
|
|
227
|
+
targetFace,
|
|
228
|
+
facing: record.origin?.facing ?? 'meet',
|
|
229
|
+
gap: typeof gap === 'number' ? gap : 0,
|
|
230
|
+
twist,
|
|
231
|
+
...kept
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
185
235
|
/**
|
|
186
236
|
* The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
|
|
187
237
|
* from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
|
|
@@ -84,9 +84,24 @@ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): strin
|
|
|
84
84
|
/** The templates a category and words find: a word matches the title, the description, the name or a tag. */
|
|
85
85
|
export function recipesMatching<T extends { category: string; title: string; description: string; name: string; tags: string[] }>(templates: readonly T[], category: string, query: string): T[] {
|
|
86
86
|
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
87
|
-
|
|
87
|
+
const found = templates.filter(t => {
|
|
88
88
|
if (category && t.category !== category) return false
|
|
89
89
|
const text = [t.title, t.description, t.name, ...t.tags].join(' ').toLowerCase()
|
|
90
90
|
return words.every(w => text.includes(w))
|
|
91
91
|
})
|
|
92
|
+
if (!words.length) return found
|
|
93
|
+
/*
|
|
94
|
+
What the search names comes first: a title that is the words, then one that starts with them, then a title word that
|
|
95
|
+
does, then the rest in their order. 「다리」 listed 사다리 before 다리 넷 (walk 1, 2026-09-26).
|
|
96
|
+
*/
|
|
97
|
+
const rank = (t: T) => {
|
|
98
|
+
const title = t.title.toLowerCase()
|
|
99
|
+
const query = words.join(' ')
|
|
100
|
+
if (title === query) return 0
|
|
101
|
+
if (title.startsWith(query)) return 1
|
|
102
|
+
if (words.every(w => title.split(/\s+/).some(part => part.startsWith(w)))) return 2
|
|
103
|
+
if (words.every(w => title.includes(w))) return 3
|
|
104
|
+
return 4
|
|
105
|
+
}
|
|
106
|
+
return found.map((t, i) => ({ t, i, r: rank(t) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.t)
|
|
92
107
|
}
|
|
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from 'lit';
|
|
|
4
4
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
5
5
|
import { i18next, localize } from '@operato/i18n';
|
|
6
6
|
import { V3_FACES } from '@hatiolab/figure-model';
|
|
7
|
-
import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
|
|
7
|
+
import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, fasteningOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
|
|
8
8
|
import { SidePanelStyles } from './side-panel-styles.js';
|
|
9
9
|
import { HelpToggles } from './v3-help.js';
|
|
10
10
|
const FACES = Object.keys(V3_FACES);
|
|
@@ -85,7 +85,9 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
85
85
|
*/
|
|
86
86
|
willUpdate(changed) {
|
|
87
87
|
if (changed.has('partId') && changed.get('partId') !== undefined) {
|
|
88
|
-
|
|
88
|
+
/* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
|
|
89
|
+
const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null;
|
|
90
|
+
this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
|
|
89
91
|
this.motion = freshMotion();
|
|
90
92
|
this.place = freshPlace();
|
|
91
93
|
this.row = freshRow();
|
|
@@ -294,7 +296,11 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
294
296
|
/* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
|
|
295
297
|
/* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
|
|
296
298
|
const plane = mountingPlaneOf(asset);
|
|
297
|
-
|
|
299
|
+
/* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
|
|
300
|
+
const mineFaces = [
|
|
301
|
+
...facesOf(asset, this.partId).filter(face => FACES.includes(face) && (!f.toPlane || plane.faces.includes(face))),
|
|
302
|
+
...(f.toPlane ? [] : ['centre'])
|
|
303
|
+
];
|
|
298
304
|
const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0];
|
|
299
305
|
const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
|
|
300
306
|
const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
|