@things-factory/figure-ui 10.1.71 → 10.1.73
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/figure-v3-recipe-catalog.ts +22 -4
- package/client/modeller/v3-editor.ts +1 -1
- 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/figure-v3-recipe-catalog.d.ts +2 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js +21 -4
- package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -1
- package/dist-client/modeller/v3-editor.js +1 -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/route.d.ts +1 -1
- package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/browser/build.mjs +12 -0
- package/test/browser/modeller-page.js +28 -2
- package/test/browser/modeller.check.mjs +47 -5
- package/test/browser/serve.mjs +7 -1
- 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 +13 -1
- package/translations/en.json +5 -2
- package/translations/ja.json +4 -1
- package/translations/ko.json +5 -2
- package/translations/ms.json +4 -1
- package/translations/zh.json +4 -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>
|
|
@@ -239,8 +239,23 @@ export class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
|
|
|
239
239
|
@state() private query = ''
|
|
240
240
|
@state() private chosen = ''
|
|
241
241
|
@state() private values: Record<string, V3RecipeValue> = {}
|
|
242
|
+
/*
|
|
243
|
+
The guide as a page \`ox-markdown\` can read: it renders what its src points to, not text, and given the text it
|
|
244
|
+
fetched it as a path and drew the app's own page in its place (seen on :3300, 2026-09-26). A blob of type
|
|
245
|
+
text/markdown is read as markdown.
|
|
246
|
+
*/
|
|
242
247
|
@state() private guide = ''
|
|
243
248
|
|
|
249
|
+
private set guideText(text: string) {
|
|
250
|
+
if (this.guide) URL.revokeObjectURL(this.guide)
|
|
251
|
+
this.guide = text ? URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) : ''
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
disconnectedCallback() {
|
|
255
|
+
super.disconnectedCallback()
|
|
256
|
+
this.guideText = ''
|
|
257
|
+
}
|
|
258
|
+
|
|
244
259
|
render() {
|
|
245
260
|
const language = i18next.language || 'en'
|
|
246
261
|
const categories = [...new Set(this.templates.map(t => t.category))].sort()
|
|
@@ -283,16 +298,19 @@ export class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
|
|
|
283
298
|
if (this.chosen === t.name) return
|
|
284
299
|
this.chosen = t.name
|
|
285
300
|
this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]))
|
|
286
|
-
this.
|
|
301
|
+
this.guideText = ''
|
|
287
302
|
this.guideOf?.(t.name)
|
|
288
|
-
.then(text => this.chosen === t.name && (this.
|
|
303
|
+
.then(text => this.chosen === t.name && (this.guideText = text))
|
|
289
304
|
.catch(() => {})
|
|
290
305
|
}
|
|
291
306
|
|
|
292
307
|
protected willUpdate() {
|
|
293
|
-
/*
|
|
308
|
+
/*
|
|
309
|
+
The first card shown is chosen while none of those shown is, so the detail is never empty and never of a card the
|
|
310
|
+
search or the category has taken away (the air cylinder stayed told under a search for rollers, :3300, 2026-09-26).
|
|
311
|
+
*/
|
|
294
312
|
const shown = recipesMatching(this.templates, this.category, this.query)
|
|
295
|
-
if (!
|
|
313
|
+
if (!shown.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])
|
|
296
314
|
}
|
|
297
315
|
|
|
298
316
|
private renderDetail(t: RecipeTemplate, language: string) {
|
|
@@ -1228,7 +1228,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
1228
1228
|
name-field
|
|
1229
1229
|
aria-label=${i18next.t('figure.label.name')}
|
|
1230
1230
|
.value=${partLabelOf(this.selected)}
|
|
1231
|
-
?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
|
|
1231
|
+
?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)?.index}
|
|
1232
1232
|
@change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
|
|
1233
1233
|
/>
|
|
1234
1234
|
</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];
|