@things-factory/figure-ui 10.1.244 → 10.1.246
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/graphql/index.ts +15 -0
- package/client/modeller/align.ts +51 -13
- package/client/modeller/clips.ts +51 -1
- package/client/modeller/data-interface.ts +16 -0
- package/client/modeller/editor-styles.ts +6 -0
- package/client/modeller/editor.ts +112 -25
- package/client/modeller/fasten-form.ts +8 -1
- package/client/modeller/fasten-geometry.ts +24 -2
- package/client/modeller/figure-clips.ts +137 -4
- package/client/modeller/figure-fasten-dialog.ts +1 -0
- package/client/modeller/figure-fasten-drawing.ts +11 -5
- package/client/modeller/figure-links.ts +25 -0
- package/client/modeller/figure-material-panel.ts +54 -4
- package/client/modeller/figure-parts.ts +7 -2
- package/client/modeller/figure-pipe-panel.ts +215 -0
- package/client/modeller/figure-preview.ts +125 -4
- package/client/modeller/figure-section-editor.ts +36 -3
- package/client/modeller/links.ts +24 -2
- package/client/modeller/material-edit.ts +9 -0
- package/client/modeller/maturity.ts +15 -4
- package/client/modeller/palette.ts +9 -0
- package/client/modeller/pipe-path.ts +21 -0
- package/client/modeller/refusals.ts +66 -1
- package/client/modeller/section-controls.ts +2 -1
- package/client/modeller/section-drawing.ts +20 -1
- package/client/modeller/starting-shape.ts +8 -5
- package/client/pages/figure-list-page.ts +15 -5
- package/dist-client/graphql/index.d.ts +2 -0
- package/dist-client/graphql/index.js +15 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/align.d.ts +28 -7
- package/dist-client/modeller/align.js +37 -12
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +24 -0
- package/dist-client/modeller/clips.js +53 -1
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/data-interface.d.ts +6 -0
- package/dist-client/modeller/data-interface.js +17 -0
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +6 -0
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +10 -1
- package/dist-client/modeller/editor.js +117 -26
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +2 -0
- package/dist-client/modeller/fasten-form.js +5 -1
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +8 -1
- package/dist-client/modeller/fasten-geometry.js +22 -1
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +9 -0
- package/dist-client/modeller/figure-clips.js +137 -4
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-fasten-dialog.js +1 -0
- package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
- package/dist-client/modeller/figure-fasten-drawing.d.ts +3 -1
- package/dist-client/modeller/figure-fasten-drawing.js +13 -5
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +5 -0
- package/dist-client/modeller/figure-links.js +22 -0
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-material-panel.d.ts +2 -0
- package/dist-client/modeller/figure-material-panel.js +53 -4
- package/dist-client/modeller/figure-material-panel.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +1 -1
- package/dist-client/modeller/figure-parts.js +5 -1
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-pipe-panel.d.ts +29 -0
- package/dist-client/modeller/figure-pipe-panel.js +221 -0
- package/dist-client/modeller/figure-pipe-panel.js.map +1 -0
- package/dist-client/modeller/figure-preview.d.ts +8 -0
- package/dist-client/modeller/figure-preview.js +128 -4
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +5 -0
- package/dist-client/modeller/figure-section-editor.js +33 -3
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/links.d.ts +6 -0
- package/dist-client/modeller/links.js +17 -2
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/material-edit.d.ts +8 -0
- package/dist-client/modeller/material-edit.js +8 -0
- package/dist-client/modeller/material-edit.js.map +1 -1
- package/dist-client/modeller/maturity.js +16 -5
- package/dist-client/modeller/maturity.js.map +1 -1
- package/dist-client/modeller/palette.js +9 -0
- package/dist-client/modeller/palette.js.map +1 -1
- package/dist-client/modeller/pipe-path.d.ts +19 -0
- package/dist-client/modeller/pipe-path.js +14 -0
- package/dist-client/modeller/pipe-path.js.map +1 -0
- package/dist-client/modeller/refusals.d.ts +12 -1
- package/dist-client/modeller/refusals.js +67 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/section-controls.js +2 -1
- package/dist-client/modeller/section-controls.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +5 -0
- package/dist-client/modeller/section-drawing.js +19 -1
- package/dist-client/modeller/section-drawing.js.map +1 -1
- package/dist-client/modeller/starting-shape.d.ts +9 -2
- package/dist-client/modeller/starting-shape.js +8 -3
- package/dist-client/modeller/starting-shape.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +1 -0
- package/dist-client/pages/figure-list-page.js +12 -3
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/test/align.test.ts +28 -6
- package/test/browser/editing-page.js +21 -1
- package/test/browser/editing.check.mjs +124 -0
- package/test/browser/links.check.mjs +49 -8
- package/test/browser/modeller.check.mjs +27 -1
- package/test/browser/part-ids.check.mjs +60 -3
- package/test/browser/parts.check.mjs +2 -2
- package/test/browser/places-mesh.check.mjs +14 -8
- package/test/browser/screen.check.mjs +54 -1
- package/test/browser/section-page.js +7 -1
- package/test/browser/section.check.mjs +81 -0
- package/test/browser/side-panel-sweep.check.mjs +13 -0
- package/test/clips.test.ts +30 -1
- package/test/data-motion.test.ts +14 -1
- package/test/fasten-geometry.test.ts +13 -2
- package/test/joint-frames.test.ts +12 -2
- package/test/links.test.ts +24 -0
- package/test/material.test.ts +15 -1
- package/test/maturity.test.ts +19 -1
- package/test/palette-icons.test.ts +2 -1
- package/test/pipe-panel.test.ts +33 -0
- package/test/refusals.test.ts +29 -0
- package/test/revision.test.ts +1 -1
- package/test/section-editor.test.ts +17 -1
- package/translations/en.json +86 -3
- package/translations/es.json +86 -3
- package/translations/ja.json +86 -3
- package/translations/ko.json +86 -3
- package/translations/ms.json +86 -3
- package/translations/zh.json +86 -3
package/client/graphql/index.ts
CHANGED
|
@@ -75,6 +75,21 @@ const FIGURE_LIST_FIELDS = `
|
|
|
75
75
|
* filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,
|
|
76
76
|
* 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.
|
|
77
77
|
*/
|
|
78
|
+
/** How many of the domain's figures each state chip would show (draft, released), under the list's other conditions. */
|
|
79
|
+
export async function fetchFigureStateCounts(filters: unknown[]): Promise<Map<string, number>> {
|
|
80
|
+
const response = await client.query({
|
|
81
|
+
query: gql`
|
|
82
|
+
query ($filters: [Filter!]) {
|
|
83
|
+
figureStateCounts(filters: $filters) { value count }
|
|
84
|
+
}
|
|
85
|
+
`,
|
|
86
|
+
variables: { filters },
|
|
87
|
+
fetchPolicy: 'no-cache'
|
|
88
|
+
})
|
|
89
|
+
if (response.errors?.length) throw new Error(response.errors.map(one => one.message).join(' · '))
|
|
90
|
+
return new Map((response.data?.figureStateCounts ?? []).map((row: { value: string; count: number }) => [row.value, row.count]))
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
export async function fetchFigureList(params: {
|
|
79
94
|
page?: number
|
|
80
95
|
limit?: number
|
package/client/modeller/align.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
|
|
2
|
-
import type { FigureInputChange, FigureWorldBox } from '@hatiolab/figure-model'
|
|
1
|
+
import { figureHangingChainOf, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureAsset, FigureInputChange, FigurePartEdit, FigureWorldBox } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
import { derivedOf, type FigureRevision } from './revision.js'
|
|
5
5
|
|
|
@@ -7,7 +7,7 @@ import type { GizmoReach } from './gizmo.js'
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 맞추기 — the align tools on a part: centre on X, centre on Z, stand on the floor,
|
|
10
|
-
*
|
|
10
|
+
* snap to the grid; and whether a mirrored copy is offered.
|
|
11
11
|
*
|
|
12
12
|
* The asset's frame has the floor at y 0 and X and Z centred on 0, so centring puts the part's box centre on 0 and the floor puts its lowest point on
|
|
13
13
|
* y 0. The part's box is its evaluated, rotated extent (`figureOccupiedBoxesOf`), so a turned part lands by what it
|
|
@@ -72,20 +72,58 @@ export function alignChanges(revision: FigureRevision, part: string, reach: Gizm
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
76
|
-
* `
|
|
75
|
+
* Mirroring a part that already sits on the centre plane would put the copy inside it, so it is not offered. The copy
|
|
76
|
+
* itself is figure-model's `mirror-part`, which places it -- fastened or not -- and refuses what it cannot mirror.
|
|
77
77
|
*/
|
|
78
|
-
export function
|
|
78
|
+
export function canMirror(revision: FigureRevision, part: string, axis: 'x' | 'z'): boolean {
|
|
79
79
|
const box = boxOf(revision, part)
|
|
80
|
-
|
|
81
|
-
const id = `${part}.position.${axis}`
|
|
82
|
-
return [{ kind: 'set-design', input: `${copy}.position.${axis}`, unit: 'mm', value: Math.round((revision.asset.designInputs[id] ?? 0) - 2 * box.centre[axis]) }]
|
|
80
|
+
return !!box && Math.abs(box.centre[axis]) >= 0.5
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
/**
|
|
86
|
-
export
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
/** How a mirrored copy is made: with what hangs from the part, under which names, turning as a mirror or as the original. */
|
|
84
|
+
export interface MirrorForm {
|
|
85
|
+
axis: 'x' | 'z'
|
|
86
|
+
chain: boolean
|
|
87
|
+
/** The beginning swapped in the copies' names (\`fl\` → \`fr\`); empty, the copies take \`-copy\`. */
|
|
88
|
+
from: string
|
|
89
|
+
to: string
|
|
90
|
+
keepMotion: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* The beginning a mirror across each axis swaps, by the figure's faces: X is its left and right, Z its front and back. */
|
|
94
|
+
const SWAPS: Record<'x' | 'z', Record<string, string>> = { x: { l: 'r', r: 'l' }, z: { f: 'b', b: 'f' } }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a mirror of this part asks before it is made: the parts hanging from it (a leg from its hip), whether any of them
|
|
98
|
+
* moves, and the form started -- the chain taken, the names' beginning guessed from the part's own (\`fl-hip\` across Z
|
|
99
|
+
* is \`bl-\`, across X \`fr-\`, by the figure's faces), the motion mirrored. A part with nothing hanging and nothing
|
|
100
|
+
* moving asks nothing.
|
|
101
|
+
*/
|
|
102
|
+
export function mirrorPlanOf(asset: FigureAsset, part: string, axis: 'x' | 'z'): { hanging: string[]; moves: boolean; asks: boolean; form: MirrorForm } {
|
|
103
|
+
const chain = figureHangingChainOf(asset, part)
|
|
104
|
+
const moves = (asset.joints ?? []).some(j => chain.includes(j.body1.part) && !!j.motion?.length)
|
|
105
|
+
const head = /^([a-zA-Z]{1,3})-/.exec(part)?.[1] ?? ''
|
|
106
|
+
const swapped = [...head].map(c => SWAPS[axis][c.toLowerCase()] ?? c).join('')
|
|
107
|
+
const named = head && swapped !== head && chain.every(p => p.startsWith(`${head}-`))
|
|
108
|
+
const hanging = chain.slice(1)
|
|
109
|
+
return {
|
|
110
|
+
hanging,
|
|
111
|
+
moves,
|
|
112
|
+
asks: hanging.length > 0 || moves,
|
|
113
|
+
form: { axis, chain: true, from: named ? `${head}-` : '', to: named ? `${swapped}-` : '', keepMotion: false }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The one edit the form comes to. */
|
|
118
|
+
export function mirrorEditOf(part: string, form: MirrorForm): FigurePartEdit {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'mirror-part',
|
|
121
|
+
target: part,
|
|
122
|
+
axis: form.axis,
|
|
123
|
+
...(form.chain ? { chain: true } : {}),
|
|
124
|
+
...(form.from && form.to && form.from !== form.to ? { prefix: { from: form.from, to: form.to } } : {}),
|
|
125
|
+
...(form.keepMotion ? { keepMotion: true } : {})
|
|
126
|
+
}
|
|
89
127
|
}
|
|
90
128
|
|
|
91
129
|
/**
|
package/client/modeller/clips.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { driverValue } from '@hatiolab/figure-model'
|
|
1
|
+
import { driverValue, figureShiftedDriverKeys } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureAsset, FigureDriver, FigureInputChange } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
/*
|
|
@@ -111,12 +111,62 @@ export function withKey(keys: readonly ClipKey[], key: ClipKey): ClipKey[] {
|
|
|
111
111
|
return [...keys.filter(k => k.seconds !== key.seconds), key].sort((a, b) => a.seconds - b.seconds)
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A key typed where it stands: its moment, its value, or both. The first key stays at 0 (a track starts there); a moment
|
|
116
|
+
* outside the clip, or on another key's, is refused (null) rather than taking that key's place unseen.
|
|
117
|
+
*/
|
|
118
|
+
export function withKeyChanged(keys: readonly ClipKey[], index: number, patch: Partial<ClipKey>, duration: number): ClipKey[] | null {
|
|
119
|
+
const was = keys[index]
|
|
120
|
+
if (!was) return null
|
|
121
|
+
const next = { seconds: patch.seconds ?? was.seconds, value: patch.value ?? was.value }
|
|
122
|
+
if (!Number.isFinite(next.seconds) || !Number.isFinite(next.value)) return null
|
|
123
|
+
if (index === 0 && next.seconds !== 0) return null
|
|
124
|
+
if (next.seconds < 0 || next.seconds > duration) return null
|
|
125
|
+
if (keys.some((k, i) => i !== index && k.seconds === next.seconds)) return null
|
|
126
|
+
return keys.map((k, i) => (i === index ? next : k)).sort((a, b) => a.seconds - b.seconds)
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
/** A track's keys without one; a track keeps at least its two ends, as the format asks. */
|
|
115
130
|
export function withoutKey(keys: readonly ClipKey[], index: number): ClipKey[] | null {
|
|
116
131
|
if (keys.length <= 2 || index === 0) return null
|
|
117
132
|
return keys.filter((_, i) => i !== index)
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
/** A name no clip has, for a copy: `walk-copy`, then `walk-copy-2`, … */
|
|
136
|
+
export function freeClipNameOf(asset: FigureAsset, clip: string): string {
|
|
137
|
+
const taken = new Set((asset.drivers ?? []).map(d => d.clip))
|
|
138
|
+
const base = `${clip}-copy`
|
|
139
|
+
if (!taken.has(base)) return base
|
|
140
|
+
let n = 2
|
|
141
|
+
while (taken.has(`${base}-${n}`)) n++
|
|
142
|
+
return `${base}-${n}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The controls a track of `clip` can be copied onto: those it plays already, and those it may take. */
|
|
146
|
+
export const copyTargetsOf = (asset: FigureAsset, clip: string): string[] => [
|
|
147
|
+
...new Set([...(asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state), ...undrivenOf(asset, clip)])
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
/** Whether a track can be moved round its loop: one that wraps and does not add up (figure-model's rule). */
|
|
151
|
+
export const shiftable = (track: ClipTrack) => track.loop === 'wrap' && !track.accumulates
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A track copied onto `target` -- itself, or another control -- played `seconds` later round its loop (robot dog
|
|
155
|
+
* 2026-11-03: a leg's swing half a period after another's). One edit; a track the clip plays on `target` is replaced.
|
|
156
|
+
*/
|
|
157
|
+
export function copiedTrackEdit(asset: FigureAsset, clip: Clip, track: ClipTrack, target: string, seconds: number) {
|
|
158
|
+
const driver = (asset.drivers ?? []).find(d => d.clip === clip.name && d.state === track.state)!
|
|
159
|
+
return {
|
|
160
|
+
kind: 'set-driver' as const,
|
|
161
|
+
state: target,
|
|
162
|
+
clip: clip.name,
|
|
163
|
+
keys: seconds ? figureShiftedDriverKeys(driver, seconds) : track.keys,
|
|
164
|
+
loop: track.loop,
|
|
165
|
+
interpolation: track.interpolation,
|
|
166
|
+
accumulates: track.accumulates
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
/** The edit that writes a track back. */
|
|
121
171
|
export const trackEdit = (clip: Clip, track: ClipTrack, patch: Partial<Pick<ClipTrack, 'keys' | 'loop' | 'interpolation'>> = {}) => ({
|
|
122
172
|
kind: 'set-driver' as const,
|
|
@@ -390,6 +390,22 @@ export function sampleOf(
|
|
|
390
390
|
/** The rows a person gives a value by hand, as a board's saved values: one value, read by a binding, not inside a list. */
|
|
391
391
|
export const valueRowsOf = (asset: FigureAsset): FigureDataRow[] => schemaRowsOf(asset).filter(r => isValue(r.node) && r.bindings.length > 0 && !r.path.includes('[]'))
|
|
392
392
|
|
|
393
|
+
/**
|
|
394
|
+
* What the data gives a row, read the way a placed figure reads its data: when it is there, it is what the figure shows,
|
|
395
|
+
* and a value saved by hand for the row is not read (things-scene's FigureDataReader: live data first). Undefined when
|
|
396
|
+
* the data leaves the row out, or gives it null.
|
|
397
|
+
*/
|
|
398
|
+
export function dataValueAt(data: unknown, path: string): unknown {
|
|
399
|
+
const steps = parseFigureAccessor(path)
|
|
400
|
+
if (!steps) return undefined
|
|
401
|
+
let at: unknown = data
|
|
402
|
+
for (const step of steps) {
|
|
403
|
+
if (at === null || typeof at !== 'object') return undefined
|
|
404
|
+
at = 'field' in step ? (at as Record<string, unknown>)[step.field] : (at as unknown[])[step.index]
|
|
405
|
+
}
|
|
406
|
+
return at === null ? undefined : at
|
|
407
|
+
}
|
|
408
|
+
|
|
393
409
|
/** A value typed or chosen for a row, as its kind reads it: a number (NaN when empty), true or false, or the text. */
|
|
394
410
|
export function typedValueOf(node: FigureDataNode, typed: string): FigureDataValue {
|
|
395
411
|
if (node.kind === 'number') return typed.trim() === '' ? Number.NaN : Number(typed)
|
|
@@ -447,4 +447,10 @@ export const EditorStyles = css`
|
|
|
447
447
|
color: var(--md-sys-color-on-surface-variant);
|
|
448
448
|
line-height: 1.5;
|
|
449
449
|
}
|
|
450
|
+
/* A mirror asked about: under its row, the panel's own field rows (what comes with it, its names, how it turns). */
|
|
451
|
+
div[mirror-form] {
|
|
452
|
+
display: grid;
|
|
453
|
+
gap: 6px;
|
|
454
|
+
margin: 4px 0 8px;
|
|
455
|
+
}
|
|
450
456
|
`
|
|
@@ -8,7 +8,7 @@ import { openPopup } from '@operato/layout'
|
|
|
8
8
|
import { OxPrompt } from '@operato/popup'
|
|
9
9
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
10
10
|
import { live } from 'lit/directives/live.js'
|
|
11
|
-
import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
|
|
11
|
+
import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureLayDownOf, figureHangingChainOf, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
|
|
12
12
|
import type { RecipeTemplate } from './recipes.js'
|
|
13
13
|
import './figure-recipe-catalog.js'
|
|
14
14
|
import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
|
|
@@ -19,6 +19,7 @@ import './canvas.js'
|
|
|
19
19
|
import './figure-parts.js'
|
|
20
20
|
import './figure-material-panel.js'
|
|
21
21
|
import './figure-section-panel.js'
|
|
22
|
+
import './figure-pipe-panel.js'
|
|
22
23
|
import './figure-section-editor.js'
|
|
23
24
|
import { faceHighlightOf, rowWordsOf } from './command-preview.js'
|
|
24
25
|
import { CommandPreview, NO_WORDS } from './command-preview-controller.js'
|
|
@@ -47,7 +48,7 @@ import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type Field
|
|
|
47
48
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
48
49
|
import { EditorStyles } from './editor-styles.js'
|
|
49
50
|
import { ScrollbarStyles } from '@operato/styles'
|
|
50
|
-
import { alignChanges, canMirror,
|
|
51
|
+
import { alignChanges, canMirror, mirrorEditOf, mirrorPlanOf, type AlignTool, type MirrorForm } from './align.js'
|
|
51
52
|
import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
|
|
52
53
|
import { paletteRecipe } from './palette.js'
|
|
53
54
|
import { meshChoicesOf, meshPartOf } from './mesh-import.js'
|
|
@@ -65,7 +66,7 @@ import { nudgeOf, placementOf } from './stage-model.js'
|
|
|
65
66
|
type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
|
|
66
67
|
import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
|
|
67
68
|
import type { FigureGateFinding } from '../types.js'
|
|
68
|
-
import { editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
|
|
69
|
+
import { defaultsMovedOf, editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
|
|
69
70
|
import type { FigureRenderPart } from './renderer.js'
|
|
70
71
|
import type { FigureBudgetLine } from './maturity.js'
|
|
71
72
|
import { HelpToggles } from './help.js'
|
|
@@ -156,6 +157,8 @@ export class FigureEditor extends LitElement {
|
|
|
156
157
|
* the tab or the middle changes under it.
|
|
157
158
|
*/
|
|
158
159
|
@state() private fastening = false
|
|
160
|
+
/** A mirrored copy being asked about (with what hangs from it, under which names), for the part selected. */
|
|
161
|
+
@state() private mirroring?: { part: string; form: MirrorForm; hanging: string[]; moves: boolean }
|
|
159
162
|
private fastenPopup?: { close: () => void }
|
|
160
163
|
private fastenWindow?: HTMLElement & {
|
|
161
164
|
revision?: FigureRevision
|
|
@@ -388,11 +391,12 @@ export class FigureEditor extends LitElement {
|
|
|
388
391
|
return code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error)
|
|
389
392
|
}
|
|
390
393
|
private run(action: () => unknown) {
|
|
394
|
+
const before = this.shown()?.asset
|
|
391
395
|
try {
|
|
392
396
|
action()
|
|
393
397
|
this.error = ''
|
|
394
|
-
/* The next step stands on its own: what the last removal let go of is no longer the news. */
|
|
395
|
-
this.notice =
|
|
398
|
+
/* The next step stands on its own: what the last removal let go of is no longer the news -- what this one moved is. */
|
|
399
|
+
this.notice = defaultsMovedOf(before, this.shown()?.asset, (key, params) => i18next.t(key, params))
|
|
396
400
|
this.changed()
|
|
397
401
|
} catch (error) {
|
|
398
402
|
if (this.refusals.askClipLoss(error, () => this.run(action))) return
|
|
@@ -763,16 +767,54 @@ export class FigureEditor extends LitElement {
|
|
|
763
767
|
if (changes.length) this.session!.apply(changes)
|
|
764
768
|
})
|
|
765
769
|
}
|
|
766
|
-
/**
|
|
770
|
+
/**
|
|
771
|
+
* A mirrored copy, one step: figure-model's mirror-part, a fastened part's record reflected with it. A part with parts
|
|
772
|
+
* hanging from it, or moving, is asked about first -- what comes with it, its names, how it turns (robot dog 2026-11-03).
|
|
773
|
+
*/
|
|
767
774
|
private mirror(axis: 'x' | 'z') {
|
|
775
|
+
const plan = mirrorPlanOf(this.shown()!.asset, this.selected, axis)
|
|
776
|
+
if (plan.asks) this.mirroring = { part: this.selected, form: plan.form, hanging: plan.hanging, moves: plan.moves }
|
|
777
|
+
else this.mirrorWith(this.selected, plan.form)
|
|
778
|
+
}
|
|
779
|
+
private mirrorWith(part: string, form: MirrorForm) {
|
|
768
780
|
this.run(() => {
|
|
769
|
-
|
|
770
|
-
const copy = this.session!.edit(
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
this.selected = copy
|
|
781
|
+
/* The copy of the part itself, the first of what the edit made. */
|
|
782
|
+
const copy = this.session!.edit(mirrorEditOf(part, form)).made?.[0]
|
|
783
|
+
this.mirroring = undefined
|
|
784
|
+
if (copy) this.selected = copy
|
|
774
785
|
})
|
|
775
786
|
}
|
|
787
|
+
private renderMirrorForm(unavailable: boolean) {
|
|
788
|
+
const m = this.mirroring
|
|
789
|
+
if (!m || m.part !== this.selected) return nothing
|
|
790
|
+
const set = (patch: Partial<MirrorForm>) => (this.mirroring = { ...m, form: { ...m.form, ...patch } })
|
|
791
|
+
const text = (key: 'from' | 'to') => (e: Event) => set({ [key]: (e.target as HTMLInputElement).value.trim() })
|
|
792
|
+
return html`<div mirror-form>
|
|
793
|
+
${m.hanging.length
|
|
794
|
+
? html`<label toggle>
|
|
795
|
+
<input type="checkbox" .checked=${m.form.chain} @change=${(e: Event) => set({ chain: (e.target as HTMLInputElement).checked })} />
|
|
796
|
+
${i18next.t('figure.label.mirror-with-hanging', { count: m.hanging.length })}
|
|
797
|
+
</label>`
|
|
798
|
+
: nothing}
|
|
799
|
+
<div form>
|
|
800
|
+
<span>${i18next.t('figure.label.mirror-names')}</span>
|
|
801
|
+
<div pair>
|
|
802
|
+
<input aria-label=${i18next.t('figure.label.mirror-names-from')} placeholder=${i18next.t('figure.label.mirror-names-from')} .value=${m.form.from} @change=${text('from')} />
|
|
803
|
+
<input aria-label=${i18next.t('figure.label.mirror-names-to')} placeholder=${i18next.t('figure.label.mirror-names-to')} .value=${m.form.to} @change=${text('to')} />
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
${m.moves
|
|
807
|
+
? html`<label toggle title=${i18next.t('figure.text.mirror-keep-motion-explained')}>
|
|
808
|
+
<input type="checkbox" .checked=${m.form.keepMotion} @change=${(e: Event) => set({ keepMotion: (e.target as HTMLInputElement).checked })} />
|
|
809
|
+
${i18next.t('figure.label.mirror-keep-motion')}
|
|
810
|
+
</label>`
|
|
811
|
+
: nothing}
|
|
812
|
+
<div pair>
|
|
813
|
+
<button act ?disabled=${unavailable} @click=${() => this.mirrorWith(m.part, m.form)}>${i18next.t('figure.button.mirror-' + m.form.axis)}</button>
|
|
814
|
+
<button act @click=${() => (this.mirroring = undefined)}>${i18next.t('figure.button.cancel')}</button>
|
|
815
|
+
</div>
|
|
816
|
+
</div>`
|
|
817
|
+
}
|
|
776
818
|
/**
|
|
777
819
|
* The one-off "put it on the plane", by the figure's placement (designer 2026-09-27): figure-model's rule
|
|
778
820
|
* (`figurePlaneAlignChanges`) -- a floor figure's part to the floor by its bottom, a ceiling one's to the ceiling by
|
|
@@ -790,16 +832,29 @@ export class FigureEditor extends LitElement {
|
|
|
790
832
|
}
|
|
791
833
|
})
|
|
792
834
|
}
|
|
835
|
+
/*
|
|
836
|
+
A part with parts hanging from it, or one on the mounting plane, is put down as a whole: the lowest point of all of it,
|
|
837
|
+
as it stands, reaches the plane -- a body by its feet (robot dog 2026-11-03).
|
|
838
|
+
*/
|
|
839
|
+
private laysDownWhole(asset: FigureAsset, part: string) {
|
|
840
|
+
return figureHangingChainOf(asset, part).length > 1 || !!asset.joints?.some(j => 'plane' in j.body0 && 'part' in j.body1 && j.body1.part === part)
|
|
841
|
+
}
|
|
793
842
|
private renderPlaneAlign(shown: FigureRevision, unavailable: boolean) {
|
|
794
843
|
const placement = shown.asset.placement ?? 'floor'
|
|
795
844
|
/* A tilted part touches the plane at a point or an edge, a ball at its lowest; the tooltip says so rather than promise a face (designer 2026-09-27). */
|
|
796
|
-
const contact = this.planeContactOf(shown, this.selected)
|
|
797
|
-
const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
|
|
845
|
+
const contact = this.laysDownWhole(shown.asset, this.selected) ? 'hanging' : this.planeContactOf(shown, this.selected)
|
|
846
|
+
const told = contact === 'tilted' || contact === 'extreme' || contact === 'hanging' ? `-${contact}` : ''
|
|
798
847
|
return html`<button plane-align=${placement} ?disabled=${unavailable} title=${i18next.t(`figure.text.align-to-${placement}${told}`)}
|
|
799
848
|
@click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
|
|
800
849
|
}
|
|
801
850
|
private alignToPlane() {
|
|
802
851
|
const asset = this.shown()!.asset
|
|
852
|
+
if (this.laysDownWhole(asset, this.selected)) {
|
|
853
|
+
const { command, refusal } = figureLayDownOf(asset, this.selected)
|
|
854
|
+
if (refusal) this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
|
|
855
|
+
else if (command) this.run(() => this.session!.command(command))
|
|
856
|
+
return
|
|
857
|
+
}
|
|
803
858
|
const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
|
|
804
859
|
if (refusal) {
|
|
805
860
|
this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
|
|
@@ -811,23 +866,33 @@ export class FigureEditor extends LitElement {
|
|
|
811
866
|
private renderTools(unavailable: boolean) {
|
|
812
867
|
const reach = this.reachOf(this.selected)
|
|
813
868
|
const shown = this.shown()
|
|
814
|
-
if (!this.selected || !
|
|
869
|
+
if (!this.selected || !shown) return null
|
|
815
870
|
/*
|
|
816
871
|
* In words, two rows by what they do (user 2026-10-01: 「무엇을 하는 것인지 모르겠다」): moving the part to the figure's
|
|
817
872
|
* centre lines, onto its plane or onto the grid; and making a mirrored copy, which makes a part and moves nothing.
|
|
818
873
|
*/
|
|
819
874
|
const tool = (name: AlignTool, label: string, title: string) =>
|
|
820
875
|
html`<button ?disabled=${unavailable} title=${i18next.t(title)} @click=${() => this.align(name)}>${i18next.t(label)}</button>`
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
876
|
+
/* A fastened part is not moved by these tools, but it is mirrored: the copy's fastening is reflected with it. */
|
|
877
|
+
const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(shown, this.selected, axis) }))
|
|
878
|
+
/* On the mounting plane nothing else here moves it; putting it down writes its gap. */
|
|
879
|
+
const onPlane = !reach.translate && !!shown.asset.joints?.some(j => 'plane' in j.body0 && 'part' in j.body1 && j.body1.part === this.selected)
|
|
880
|
+
return html`${onPlane
|
|
881
|
+
? html`<div vector-row>
|
|
882
|
+
<label>${i18next.t('figure.label.tools')}</label>
|
|
883
|
+
<div tools>${this.renderPlaneAlign(shown, unavailable)}</div>
|
|
884
|
+
</div>`
|
|
885
|
+
: nothing}${reach.translate
|
|
886
|
+
? html`<div vector-row>
|
|
887
|
+
<label>${i18next.t('figure.label.tools')}</label>
|
|
888
|
+
<div tools>
|
|
889
|
+
${tool('centre-x', 'figure.button.center-x', 'figure.text.center-on-x')}
|
|
890
|
+
${tool('centre-z', 'figure.button.center-z', 'figure.text.center-on-z')}
|
|
891
|
+
${this.renderPlaneAlign(shown, unavailable)}
|
|
892
|
+
${tool('snap', 'figure.button.snap', 'figure.text.snap-explained')}
|
|
893
|
+
</div>
|
|
894
|
+
</div>`
|
|
895
|
+
: null}
|
|
831
896
|
<div vector-row>
|
|
832
897
|
<label>${i18next.t('figure.label.mirror-copy')}</label>
|
|
833
898
|
<div tools>
|
|
@@ -838,6 +903,7 @@ export class FigureEditor extends LitElement {
|
|
|
838
903
|
)}
|
|
839
904
|
</div>
|
|
840
905
|
</div>
|
|
906
|
+
${this.renderMirrorForm(unavailable)}
|
|
841
907
|
`
|
|
842
908
|
}
|
|
843
909
|
/**
|
|
@@ -912,6 +978,21 @@ export class FigureEditor extends LitElement {
|
|
|
912
978
|
const place = groups.vectors.filter(g => g.kind === 'vector' && g.name !== 'size')
|
|
913
979
|
const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(this.selected))
|
|
914
980
|
const placeLocked = fastened && place.every(g => g.kind === 'vector' && (['x', 'y', 'z'] as const).every(axis => !g.cells[axis]))
|
|
981
|
+
/* A pipe's measures are its section, wall and path, in its own panel (ADR-0100 §16.2): not raw fields of the part. */
|
|
982
|
+
const body = geometry.find(g => g.id === this.selected) as { id: string; shape?: FigureEvaluatedShape } | undefined
|
|
983
|
+
if (body?.shape?.sweep)
|
|
984
|
+
return this.renderLinks(
|
|
985
|
+
unavailable,
|
|
986
|
+
'shape',
|
|
987
|
+
html`<section slot="size" group-fields>
|
|
988
|
+
<h3>${i18next.t('figure.label.group-pipe')}</h3>
|
|
989
|
+
<!-- Its command reaches the links' own handler: it is slotted in them. -->
|
|
990
|
+
<figure-pipe-panel .body=${body} ?locked=${unavailable}></figure-pipe-panel>
|
|
991
|
+
</section>
|
|
992
|
+
${place.length && !placeLocked
|
|
993
|
+
? html`<div slot="place" group-fields>${place.map(g => this.renderVector(g, unavailable, held))}${this.renderHeldAxes(place)}</div>`
|
|
994
|
+
: nothing}`
|
|
995
|
+
)
|
|
915
996
|
return this.renderLinks(
|
|
916
997
|
unavailable,
|
|
917
998
|
'shape',
|
|
@@ -1065,12 +1146,18 @@ export class FigureEditor extends LitElement {
|
|
|
1065
1146
|
this.holdAction(null)
|
|
1066
1147
|
this.notice = ''
|
|
1067
1148
|
const hadSize = this.hasSize()
|
|
1149
|
+
const before = this.shown()?.asset
|
|
1068
1150
|
try {
|
|
1069
1151
|
this.session!.command(action)
|
|
1070
1152
|
this.error = ''
|
|
1071
1153
|
this.faceMapping = undefined
|
|
1072
|
-
/*
|
|
1154
|
+
/*
|
|
1155
|
+
What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1), a
|
|
1156
|
+
default pose its new range moved.
|
|
1157
|
+
*/
|
|
1073
1158
|
if (action.kind === 'follow-size' && !hadSize) this.notice = this.sizeDeclaredWords()
|
|
1159
|
+
const moved = defaultsMovedOf(before, this.shown()?.asset, (key, params) => i18next.t(key, params))
|
|
1160
|
+
if (moved) this.notice = [this.notice, moved].filter(Boolean).join(' ')
|
|
1074
1161
|
this.changed()
|
|
1075
1162
|
return true
|
|
1076
1163
|
} catch (error) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FIGURE_FACES, figureFastenKeptGapOf, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureAuthoringAction, FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
|
-
import { faceViewOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
|
|
4
|
+
import { faceViewOf, facesAsDrawnOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
|
|
5
5
|
import type { FastenChange } from './figure-fasten-drawing.js'
|
|
6
6
|
import {
|
|
7
7
|
alignOf,
|
|
@@ -192,9 +192,16 @@ export interface FastenPlan {
|
|
|
192
192
|
face?: { part: string; face: string }
|
|
193
193
|
/** On a point of the target's curved surface: which surface, about which axis, and where (ADR-0140 3.9). */
|
|
194
194
|
surface?: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }
|
|
195
|
+
/** The target's own face at each of the drawing's places, as the target is drawn (\`facesAsDrawnOf\`). */
|
|
196
|
+
drawnFaces?: Record<FigureFace, FigureFace>
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
|
|
200
|
+
const plan = fastenPlanWithout(revision, part, f)
|
|
201
|
+
return f.target && !f.toPlane ? { ...plan, drawnFaces: facesAsDrawnOf(revision, f.target) } : plan
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fastenPlanWithout(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
|
|
198
205
|
const asset = revision.asset
|
|
199
206
|
const theirFaces = f.target ? facesOf(asset, f.target) : FACES
|
|
200
207
|
const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { figureFaceInsideAlignOf, figureFaceMeasureOf, figureFaceOverhangAt, figureFastenPlaceOf } from '@hatiolab/figure-model'
|
|
2
|
-
import type { FigureAsset, FigureFaceMeasure, FigureFacing, FigureFastenAlignment } from '@hatiolab/figure-model'
|
|
1
|
+
import { FIGURE_FACES, figureFaceInsideAlignOf, figureFaceMeasureOf, figureFaceOverhangAt, figureFastenPlaceOf } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureAsset, FigureFace, FigureFaceMeasure, FigureFacing, FigureFastenAlignment } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
import { alignAxesOf, type Alignment } from './links.js'
|
|
5
5
|
import type { FigureRevision } from './revision.js'
|
|
@@ -16,6 +16,28 @@ type Axis = 'x' | 'y' | 'z'
|
|
|
16
16
|
export type FastenMode = FigureFacing
|
|
17
17
|
export type FastenTarget = { part: string; face: string } | 'plane'
|
|
18
18
|
|
|
19
|
+
/* Which way each of the six faces looks, along its axis, in a part's own frame. */
|
|
20
|
+
const OUTWARD: Record<FigureFace, number> = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 }
|
|
21
|
+
const SIX = Object.keys(OUTWARD) as FigureFace[]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The target's own face that stands at each of the drawing's six places as the part is drawn (robot dog, 2026-11-03):
|
|
25
|
+
* a face is named in its part's own frame, so a cylinder laid on its side has its 「아래」 on the side, and the box drawn
|
|
26
|
+
* upright showed the name where the face is not. Each place -- up, down, right, left, toward the eye, away -- shows the
|
|
27
|
+
* own face whose outward normal, turned by the part's pose, points most that way. Unturned, each is itself.
|
|
28
|
+
*/
|
|
29
|
+
export function facesAsDrawnOf(revision: FigureRevision, target: string): Record<FigureFace, FigureFace> {
|
|
30
|
+
const drawn = (revision.geometry as Array<{ id: string; pose?: { r: number[][] } }>).find(g => g.id === target)?.pose?.r
|
|
31
|
+
const same = Object.fromEntries(SIX.map(f => [f, f])) as Record<FigureFace, FigureFace>
|
|
32
|
+
if (!drawn) return same
|
|
33
|
+
const index = { x: 0, y: 1, z: 2 } as const
|
|
34
|
+
/* World direction of an own face: its column of the pose, outward. */
|
|
35
|
+
const world = (f: FigureFace) => [0, 1, 2].map(i => OUTWARD[f] * drawn[i]![index[FIGURE_FACES[f] as Axis]]!)
|
|
36
|
+
const along = (place: FigureFace) => [0, 1, 2].map(i => (i === index[FIGURE_FACES[place] as Axis] ? OUTWARD[place] : 0))
|
|
37
|
+
const dot = (a: number[], b: number[]) => a.reduce((sum, v, i) => sum + v * b[i]!, 0)
|
|
38
|
+
return Object.fromEntries(SIX.map(place => [place, SIX.reduce((best, f) => (dot(world(f), along(place)) > dot(world(best), along(place)) ? f : best))])) as Record<FigureFace, FigureFace>
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
* The face seen from above: its two axes across (as the form's alignment names them), the face's size on each as a
|
|
21
43
|
* fastening lines up to it -- none for a plane with no declared size -- the part's real extent on each, whether its
|