@things-factory/figure-ui 10.1.81 → 10.1.83
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/figure-assistant.ts +2 -2
- package/client/graphql/index.ts +99 -10
- package/client/graphql/reached.ts +25 -0
- package/client/graphql/store.ts +27 -4
- package/client/modeller/ai-proposal.ts +74 -9
- package/client/modeller/align.ts +2 -3
- package/client/modeller/canvas.ts +18 -5
- package/client/modeller/command-preview.ts +113 -0
- package/client/modeller/editor.ts +800 -53
- package/client/modeller/fields.ts +12 -4
- package/client/modeller/figure-facts.ts +2 -2
- package/client/modeller/figure-first-save.ts +36 -14
- package/client/modeller/figure-history-panel.ts +133 -14
- package/client/modeller/figure-links.ts +357 -94
- package/client/modeller/figure-parts.ts +56 -2
- package/client/modeller/figure-preview.ts +19 -1
- package/client/modeller/figure-properties.ts +3 -3
- package/client/modeller/figure-revert-choice.ts +49 -0
- package/client/modeller/first-save.ts +16 -0
- package/client/modeller/gizmo.ts +0 -16
- package/client/modeller/links.ts +142 -15
- package/client/modeller/motion-ends.ts +83 -0
- package/client/modeller/occupancy.ts +28 -2
- package/client/modeller/product-session.ts +154 -18
- package/client/modeller/refusals.ts +15 -0
- package/client/modeller/revert-request.ts +26 -0
- package/client/modeller/side-panel-styles.ts +12 -0
- package/client/modeller/stage-model.ts +25 -3
- package/client/pages/figure-modeller-page.ts +68 -29
- package/dist-client/figure-assistant.js +2 -2
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/graphql/index.d.ts +39 -2
- package/dist-client/graphql/index.js +63 -9
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/graphql/reached.d.ts +10 -0
- package/dist-client/graphql/reached.js +28 -0
- package/dist-client/graphql/reached.js.map +1 -0
- package/dist-client/graphql/store.js +27 -4
- package/dist-client/graphql/store.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +31 -2
- package/dist-client/modeller/ai-proposal.js +65 -6
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/align.d.ts +1 -1
- package/dist-client/modeller/align.js +0 -2
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/canvas.d.ts +11 -0
- package/dist-client/modeller/canvas.js +33 -4
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/command-preview.d.ts +35 -0
- package/dist-client/modeller/command-preview.js +95 -0
- package/dist-client/modeller/command-preview.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +107 -1
- package/dist-client/modeller/editor.js +823 -49
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fields.d.ts +3 -2
- package/dist-client/modeller/fields.js +10 -3
- package/dist-client/modeller/fields.js.map +1 -1
- package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
- package/dist-client/modeller/figure-clips.d.ts +1 -1
- package/dist-client/modeller/figure-controls.d.ts +1 -1
- package/dist-client/modeller/figure-facts.js +2 -2
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +3 -4
- package/dist-client/modeller/figure-first-save.js +41 -14
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +31 -0
- package/dist-client/modeller/figure-history-panel.js +130 -14
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +52 -2
- package/dist-client/modeller/figure-links.js +366 -97
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-material-panel.d.ts +1 -1
- package/dist-client/modeller/figure-parts.d.ts +7 -0
- package/dist-client/modeller/figure-parts.js +55 -2
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +6 -0
- package/dist-client/modeller/figure-preview.js +22 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +1 -1
- package/dist-client/modeller/figure-properties.js +3 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
- package/dist-client/modeller/figure-revert-choice.js +59 -0
- package/dist-client/modeller/figure-revert-choice.js.map +1 -0
- package/dist-client/modeller/first-save.d.ts +7 -0
- package/dist-client/modeller/first-save.js +17 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/gizmo.d.ts +0 -6
- package/dist-client/modeller/gizmo.js +0 -18
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/links.d.ts +49 -4
- package/dist-client/modeller/links.js +141 -15
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/motion-ends.d.ts +12 -0
- package/dist-client/modeller/motion-ends.js +65 -0
- package/dist-client/modeller/motion-ends.js.map +1 -0
- package/dist-client/modeller/occupancy.d.ts +14 -1
- package/dist-client/modeller/occupancy.js +14 -1
- package/dist-client/modeller/occupancy.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +43 -2
- package/dist-client/modeller/product-session.js +146 -12
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +13 -0
- package/dist-client/modeller/refusals.js +16 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/revert-request.d.ts +16 -0
- package/dist-client/modeller/revert-request.js +11 -0
- package/dist-client/modeller/revert-request.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.js +12 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +18 -2
- package/dist-client/modeller/stage-model.js +21 -3
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +8 -1
- package/dist-client/pages/figure-modeller-page.js +72 -34
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal.test.ts +2 -2
- package/test/align.test.ts +50 -5
- package/test/browser/acceptance-page.js +187 -0
- package/test/browser/build.mjs +5 -1
- package/test/browser/revert-page.js +47 -0
- package/test/browser/serve-acceptance.mjs +85 -0
- package/test/browser/serve-revert.mjs +119 -0
- package/test/clip-ai-proposal.test.ts +104 -0
- package/test/clip-consent.test.ts +88 -0
- package/test/command-preview.test.ts +288 -0
- package/test/fields.test.ts +4 -3
- package/test/figure-assistant-surface.test.ts +4 -1
- package/test/first-minute.test.ts +96 -0
- package/test/first-save-suggest.test.ts +42 -0
- package/test/gizmo.test.ts +20 -16
- package/test/joint-fan.test.ts +2 -1
- package/test/layout.test.ts +9 -2
- package/test/links.test.ts +9 -5
- package/test/proposal-record.test.ts +126 -0
- package/test/release-entry.test.ts +28 -0
- package/test/release-panel.test.ts +3 -2
- package/test/resize-with-placement.test.ts +226 -0
- package/test/revert-choice.test.ts +39 -0
- package/test/revert-keeping.test.ts +102 -0
- package/test/save-landed.test.ts +198 -0
- package/translations/en.json +229 -11
- package/translations/ja.json +229 -11
- package/translations/ko.json +230 -12
- package/translations/ms.json +228 -10
- package/translations/zh.json +229 -11
|
@@ -23,8 +23,8 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
|
|
|
23
23
|
window.dispatchEvent(new CustomEvent('figure-apply', { detail }))
|
|
24
24
|
return detail
|
|
25
25
|
},
|
|
26
|
-
openNew(steps) {
|
|
27
|
-
const opened = pendNewFigure(steps)
|
|
26
|
+
openNew(steps, key) {
|
|
27
|
+
const opened = pendNewFigure(steps, key)
|
|
28
28
|
navigate(modellerPathOf())
|
|
29
29
|
return opened
|
|
30
30
|
}
|
package/client/graphql/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ import gql from 'graphql-tag'
|
|
|
2
2
|
import { i18next } from '@operato/i18n'
|
|
3
3
|
import { client } from '@operato/graphql'
|
|
4
4
|
|
|
5
|
+
import { reached, refusedIn } from './reached.js'
|
|
6
|
+
|
|
5
7
|
import type {
|
|
6
8
|
Figure,
|
|
7
9
|
FigureFinding,
|
|
@@ -208,21 +210,108 @@ export async function releaseFigure(id: string, comment?: string): Promise<Figur
|
|
|
208
210
|
return unwrap<Figure>(response, 'releaseFigure')
|
|
209
211
|
}
|
|
210
212
|
|
|
211
|
-
/**
|
|
212
|
-
export
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
213
|
+
/** A draft a revert kept when it wrote over it (ADR-0097 §5.6). Not a released version. */
|
|
214
|
+
export interface KeptDraft {
|
|
215
|
+
id: string
|
|
216
|
+
kind: 'revert' | 'undo'
|
|
217
|
+
state?: string
|
|
218
|
+
version?: number
|
|
219
|
+
revertedToVersion?: number
|
|
220
|
+
restoredAt?: string
|
|
221
|
+
createdAt?: string
|
|
222
|
+
creator?: { name?: string }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const KEPT_FIELDS = `id kind state version revertedToVersion restoredAt createdAt creator { name }`
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가
|
|
229
|
+
* 보관한다. `expectedSource` 는 서버가 들고 있다고 확인된 정본 -- 그 사이 바뀌었으면 거절된다. 답을 못 받았으면
|
|
230
|
+
* (REVERT_UNREACHABLE) 같은 `requestId` 로 다시 묻는다: 서버는 같은 되돌리기로 답하고 두 번 하지 않는다.
|
|
231
|
+
*/
|
|
232
|
+
export async function revertFigureVersion(
|
|
233
|
+
id: string,
|
|
234
|
+
version: number,
|
|
235
|
+
requestId: string,
|
|
236
|
+
expectedSource: string
|
|
237
|
+
): Promise<SaveResult & { kept: KeptDraft }> {
|
|
238
|
+
const response = await reached(
|
|
239
|
+
client.mutate({
|
|
240
|
+
mutation: gql`
|
|
241
|
+
mutation RevertFigureVersion($id: String!, $version: Float!, $requestId: String!, $expectedSource: String!) {
|
|
242
|
+
revertFigureVersion(id: $id, version: $version, requestId: $requestId, expectedSource: $expectedSource) {
|
|
243
|
+
figure { ${FIGURE_LIST_FIELDS} source }
|
|
244
|
+
violations { code message at }
|
|
245
|
+
kept { ${KEPT_FIELDS} }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
`,
|
|
249
|
+
variables: { id, version, requestId, expectedSource }
|
|
250
|
+
}),
|
|
251
|
+
'REVERT_UNREACHABLE'
|
|
252
|
+
)
|
|
253
|
+
refusedIn(response)
|
|
254
|
+
return unwrap<SaveResult & { kept: KeptDraft }>(response, 'revertFigureVersion')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 되돌리기 취소 -- 보관한 초안을 되살린다. 되돌린 뒤 모델이 바뀌었으면 서버가 거절하고 보관본은 남는다. */
|
|
258
|
+
export async function undoFigureRevert(keptId: string, requestId: string): Promise<SaveResult & { kept: KeptDraft }> {
|
|
259
|
+
const response = await reached(
|
|
260
|
+
client.mutate({
|
|
261
|
+
mutation: gql`
|
|
262
|
+
mutation UndoFigureRevert($keptId: String!, $requestId: String!) {
|
|
263
|
+
undoFigureRevert(keptId: $keptId, requestId: $requestId) {
|
|
264
|
+
figure { ${FIGURE_LIST_FIELDS} source }
|
|
265
|
+
violations { code message at }
|
|
266
|
+
kept { ${KEPT_FIELDS} }
|
|
267
|
+
}
|
|
219
268
|
}
|
|
269
|
+
`,
|
|
270
|
+
variables: { keptId, requestId }
|
|
271
|
+
}),
|
|
272
|
+
'REVERT_UNREACHABLE'
|
|
273
|
+
)
|
|
274
|
+
refusedIn(response)
|
|
275
|
+
return unwrap<SaveResult & { kept: KeptDraft }>(response, 'undoFigureRevert')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A figure an AI proposal was saved into (ADR-0097 §5.6 item 5). */
|
|
279
|
+
export interface ProposalOutcome {
|
|
280
|
+
figureId: string
|
|
281
|
+
type?: string
|
|
282
|
+
name?: string
|
|
283
|
+
recordedAt?: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 도형들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,
|
|
288
|
+
* 적용하지 않았다). 도크의 「결과 확인」이 이것을 묻는다: 도크의 기억이 아니라 서버의 기록이다.
|
|
289
|
+
*/
|
|
290
|
+
export async function fetchProposalOutcome(proposalKey: string): Promise<ProposalOutcome[]> {
|
|
291
|
+
const response = await client.query({
|
|
292
|
+
query: gql`
|
|
293
|
+
query FigureProposalOutcome($proposalKey: String!) {
|
|
294
|
+
figureProposalOutcome(proposalKey: $proposalKey) { figureId type name recordedAt }
|
|
220
295
|
}
|
|
221
296
|
`,
|
|
222
|
-
variables: {
|
|
297
|
+
variables: { proposalKey },
|
|
298
|
+
fetchPolicy: 'network-only'
|
|
223
299
|
})
|
|
300
|
+
return unwrap<ProposalOutcome[]>(response, 'figureProposalOutcome') ?? []
|
|
301
|
+
}
|
|
224
302
|
|
|
225
|
-
|
|
303
|
+
/** 되돌리기가 보관한 초안들, 새것부터 -- 다시 접속해도 여기서 찾는다. */
|
|
304
|
+
export async function fetchReplacedDrafts(id: string): Promise<KeptDraft[]> {
|
|
305
|
+
const response = await client.query({
|
|
306
|
+
query: gql`
|
|
307
|
+
query FigureReplacedDrafts($id: String!) {
|
|
308
|
+
figureReplacedDrafts(id: $id) { ${KEPT_FIELDS} }
|
|
309
|
+
}
|
|
310
|
+
`,
|
|
311
|
+
variables: { id },
|
|
312
|
+
fetchPolicy: 'network-only'
|
|
313
|
+
})
|
|
314
|
+
return unwrap<KeptDraft[]>(response, 'figureReplacedDrafts')
|
|
226
315
|
}
|
|
227
316
|
|
|
228
317
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request that never reached the server, or whose answer never came back, is told apart from a refusal: what
|
|
3
|
+
* happened to the write is not known (`unreachable`, SAVE_UNREACHABLE for a save). The caller finds out before saying
|
|
4
|
+
* more -- a save reads the server's copy back, a revert asks again with the same request id.
|
|
5
|
+
*/
|
|
6
|
+
export async function reached<T>(request: Promise<T>, unreachable = 'SAVE_UNREACHABLE'): Promise<T> {
|
|
7
|
+
try {
|
|
8
|
+
return await request
|
|
9
|
+
} catch (error) {
|
|
10
|
+
const lost = (error as { networkError?: unknown })?.networkError || error instanceof TypeError
|
|
11
|
+
if (lost) throw Object.assign(Error('서버에 연결하지 못했습니다.'), { code: unreachable, cause: error })
|
|
12
|
+
/* A refusal keeps the server's code, so a conflict is told from a refusal on grounds. */
|
|
13
|
+
const refused = (error as { graphQLErrors?: Array<{ message: string; extensions?: { code?: string } }> })?.graphQLErrors
|
|
14
|
+
if (refused?.length) throw Object.assign(Error(refused.map(e => e.message).join('\n')), { code: refused[0]!.extensions?.code, cause: error })
|
|
15
|
+
throw error
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Errors carried in the answer (the client's error policy lets them through), thrown with the server's code. */
|
|
20
|
+
export function refusedIn(response: { errors?: readonly unknown[] }) {
|
|
21
|
+
if (response.errors?.length)
|
|
22
|
+
throw Object.assign(Error(response.errors.map(e => (e as { message: string }).message).join('\n')), {
|
|
23
|
+
code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code
|
|
24
|
+
})
|
|
25
|
+
}
|
package/client/graphql/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import gql from 'graphql-tag'
|
|
2
2
|
import { client } from '@operato/graphql'
|
|
3
3
|
import { fetchFigure } from './index.js'
|
|
4
|
+
import { reached } from './reached.js'
|
|
4
5
|
import type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'
|
|
5
6
|
|
|
6
7
|
/** source is sent intact. No conversion into the old parts/base authoring representation. */
|
|
@@ -10,10 +11,30 @@ export const figureProductStore: FigureProductStore = {
|
|
|
10
11
|
if (!record?.source) throw Error('Figure 정본이 없습니다.')
|
|
11
12
|
return { id: record.id, source: record.source }
|
|
12
13
|
},
|
|
14
|
+
/* What the server holds of every field a save writes, read back after a save whose outcome is not known. */
|
|
15
|
+
async readBack(id) {
|
|
16
|
+
const response = await client.query({
|
|
17
|
+
query: gql`
|
|
18
|
+
query ($id: String!) {
|
|
19
|
+
figure(id: $id) {
|
|
20
|
+
id
|
|
21
|
+
source
|
|
22
|
+
thumbnail
|
|
23
|
+
type
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
variables: { id },
|
|
28
|
+
fetchPolicy: 'network-only'
|
|
29
|
+
})
|
|
30
|
+
const figure = response.data?.figure as { id: string; source: string; thumbnail?: string | null; type?: string } | undefined
|
|
31
|
+
if (!figure?.source) throw Error('Figure 정본이 없습니다.')
|
|
32
|
+
return { id: figure.id, source: figure.source, thumbnail: figure.thumbnail ?? undefined, type: figure.type }
|
|
33
|
+
},
|
|
13
34
|
async save(request) {
|
|
14
|
-
const response = await client.mutate({
|
|
35
|
+
const response = await reached(client.mutate({
|
|
15
36
|
mutation: gql`
|
|
16
|
-
mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String) {
|
|
37
|
+
mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!]) {
|
|
17
38
|
saveFigure(
|
|
18
39
|
source: $source
|
|
19
40
|
id: $id
|
|
@@ -21,6 +42,7 @@ export const figureProductStore: FigureProductStore = {
|
|
|
21
42
|
creationId: $creationId
|
|
22
43
|
thumbnail: $thumbnail
|
|
23
44
|
type: $type
|
|
45
|
+
proposalKeys: $proposalKeys
|
|
24
46
|
) {
|
|
25
47
|
id
|
|
26
48
|
source
|
|
@@ -28,8 +50,9 @@ export const figureProductStore: FigureProductStore = {
|
|
|
28
50
|
}
|
|
29
51
|
`,
|
|
30
52
|
variables: request
|
|
31
|
-
})
|
|
32
|
-
if (response.errors?.length)
|
|
53
|
+
}))
|
|
54
|
+
if (response.errors?.length)
|
|
55
|
+
throw Object.assign(Error(response.errors.map(e => e.message).join('\n')), { code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code })
|
|
33
56
|
const result = response.data?.saveFigure as FigureProductRecord | undefined
|
|
34
57
|
if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')
|
|
35
58
|
return result
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.
|
|
7
7
|
*/
|
|
8
8
|
import type { FigureProposalStep } from '@hatiolab/figure-model'
|
|
9
|
+
import type { FigureProductSession } from './product-session.js'
|
|
9
10
|
|
|
10
11
|
/** What the card carries, as `proposeFigureCandidate` returned it. */
|
|
11
12
|
export interface FigureProposalCard {
|
|
13
|
+
/** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */
|
|
14
|
+
key?: string
|
|
12
15
|
steps?: FigureProposalStep[]
|
|
13
16
|
create?: boolean
|
|
14
17
|
figureId?: string
|
|
@@ -18,6 +21,7 @@ export interface FigureProposalCard {
|
|
|
18
21
|
|
|
19
22
|
/** The `figure-apply` event's detail. The page that takes it sets `applied`, and `error` when refused. */
|
|
20
23
|
export interface FigureApplyDetail {
|
|
24
|
+
key?: string
|
|
21
25
|
steps: FigureProposalStep[]
|
|
22
26
|
create: boolean
|
|
23
27
|
context?: string
|
|
@@ -25,6 +29,8 @@ export interface FigureApplyDetail {
|
|
|
25
29
|
applied?: boolean
|
|
26
30
|
error?: string
|
|
27
31
|
code?: string
|
|
32
|
+
/** The clips a refused proposal would have stopped (CLIPS_DROPPED), by name. */
|
|
33
|
+
clips?: string[]
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export type Say = (key: string, params?: Record<string, string>) => string
|
|
@@ -33,7 +39,7 @@ export interface FigureProposalTarget {
|
|
|
33
39
|
/** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */
|
|
34
40
|
offer(detail: FigureApplyDetail): FigureApplyDetail
|
|
35
41
|
/** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */
|
|
36
|
-
openNew(steps: FigureProposalStep[]): Promise<{ applied: boolean; error?: string; code?: string }>
|
|
42
|
+
openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
export interface FigureProposalOutcome {
|
|
@@ -43,19 +49,75 @@ export interface FigureProposalOutcome {
|
|
|
43
49
|
note?: string
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The dock's "check the result" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:
|
|
54
|
+
* accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,
|
|
55
|
+
* which the dock keeps as "not known" and never turns into a refusal. Nothing is run again.
|
|
56
|
+
*/
|
|
57
|
+
export function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {
|
|
58
|
+
if (!saved.length) return undefined
|
|
59
|
+
const names = saved.map(f => f.name || f.type || f.figureId).join(', ')
|
|
60
|
+
return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */
|
|
47
64
|
const STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])
|
|
48
65
|
|
|
49
|
-
function refused(code: string | undefined, error: string | undefined, say: Say): FigureProposalOutcome {
|
|
66
|
+
function refused(code: string | undefined, error: string | undefined, say: Say, clips?: string[]): FigureProposalOutcome {
|
|
67
|
+
/*
|
|
68
|
+
A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not
|
|
69
|
+
asked about -- the person edits by hand if they mean it (designer 2026-09-27).
|
|
70
|
+
*/
|
|
50
71
|
const note =
|
|
51
72
|
code && STALE.has(code)
|
|
52
73
|
? say('figure.text.proposal-stale')
|
|
53
74
|
: code === 'NEW_FIGURE_UNSAVED'
|
|
54
75
|
? (error ?? '')
|
|
55
|
-
:
|
|
76
|
+
: code === 'CLIPS_DROPPED'
|
|
77
|
+
? say('figure.text.proposal-would-drop-clips', { clips: (clips ?? []).join(', ') })
|
|
78
|
+
: say('figure.text.proposal-refused', { reason: error ?? '' })
|
|
56
79
|
return { verdict: 'refused', errorCode: code ?? 'refused', note }
|
|
57
80
|
}
|
|
58
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands
|
|
84
|
+
* as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new
|
|
85
|
+
* figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a
|
|
86
|
+
* proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.
|
|
87
|
+
*/
|
|
88
|
+
export function takeFigureOffer(
|
|
89
|
+
session: FigureProductSession,
|
|
90
|
+
detail: FigureApplyDetail & { changes?: unknown },
|
|
91
|
+
{ onSavedFigure, say }: { onSavedFigure: boolean; say: Say }
|
|
92
|
+
): boolean {
|
|
93
|
+
const view = session.read()
|
|
94
|
+
if (detail.create && onSavedFigure) return false
|
|
95
|
+
if (detail.create && view.dirty) {
|
|
96
|
+
/* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
|
|
97
|
+
detail.applied = false
|
|
98
|
+
detail.code = 'NEW_FIGURE_UNSAVED'
|
|
99
|
+
detail.error = say('figure.text.proposal-new-figure-unsaved')
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (detail.create) {
|
|
104
|
+
/* Created under the proposal's key: taken twice, it meets the first figure, never a second (ADR-0097 §5.6 item 5). */
|
|
105
|
+
if (detail.key) session.createUnder(detail.key)
|
|
106
|
+
session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)
|
|
107
|
+
} else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)
|
|
108
|
+
else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)
|
|
109
|
+
detail.applied = true
|
|
110
|
+
return true
|
|
111
|
+
} catch (error) {
|
|
112
|
+
detail.applied = false
|
|
113
|
+
detail.code = (error as { code?: string })?.code
|
|
114
|
+
detail.error = error instanceof Error ? error.message : String(error)
|
|
115
|
+
const clips = (error as { clips?: Array<{ clip: string }> })?.clips
|
|
116
|
+
if (clips) detail.clips = clips.map(c => c.clip)
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
59
121
|
export async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {
|
|
60
122
|
const steps = card?.steps
|
|
61
123
|
if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }
|
|
@@ -66,14 +128,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
|
|
|
66
128
|
note: say('figure.text.proposal-applied', { n: String(steps.length) })
|
|
67
129
|
})
|
|
68
130
|
|
|
69
|
-
const detail = target.offer({ steps, create, context: card.context, revision: card.revision })
|
|
131
|
+
const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })
|
|
70
132
|
if (detail.applied) return accepted()
|
|
71
|
-
if (detail.applied === false) return refused(detail.code, detail.error, say)
|
|
133
|
+
if (detail.applied === false) return refused(detail.code, detail.error, say, detail.clips)
|
|
72
134
|
|
|
73
135
|
/* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */
|
|
74
136
|
if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }
|
|
75
137
|
|
|
76
|
-
const opened = await target.openNew(steps)
|
|
138
|
+
const opened = await target.openNew(steps, card.key)
|
|
77
139
|
if (opened.applied) return accepted()
|
|
78
140
|
return opened.code === 'not-opened'
|
|
79
141
|
? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
|
|
@@ -85,13 +147,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
|
|
|
85
147
|
modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
|
|
86
148
|
figure must not receive steps nobody is waiting for.
|
|
87
149
|
*/
|
|
88
|
-
let pending: { steps: FigureProposalStep[]; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined
|
|
150
|
+
let pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined
|
|
89
151
|
|
|
90
|
-
export function pendNewFigure(steps: FigureProposalStep[], ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
|
|
152
|
+
export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
|
|
91
153
|
pending?.settle({ applied: false, code: 'not-opened' })
|
|
92
154
|
return new Promise(resolve => {
|
|
93
155
|
const mine = {
|
|
94
156
|
steps,
|
|
157
|
+
key,
|
|
95
158
|
settle: (result: { applied: boolean; error?: string; code?: string }) => {
|
|
96
159
|
clearTimeout(timer)
|
|
97
160
|
if (pending === mine) pending = undefined
|
|
@@ -104,7 +167,9 @@ export function pendNewFigure(steps: FigureProposalStep[], ms = 10_000): Promise
|
|
|
104
167
|
}
|
|
105
168
|
|
|
106
169
|
/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
|
|
107
|
-
export function takePendingNewFigure():
|
|
170
|
+
export function takePendingNewFigure():
|
|
171
|
+
| { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }
|
|
172
|
+
| undefined {
|
|
108
173
|
const taken = pending
|
|
109
174
|
pending = undefined
|
|
110
175
|
return taken
|
package/client/modeller/align.ts
CHANGED
|
@@ -14,7 +14,8 @@ import type { GizmoReach } from './gizmo.js'
|
|
|
14
14
|
* Every tool writes the part's own position (and, for snap, size) inputs — the same ones the gizmo writes, and only
|
|
15
15
|
* where the gizmo could (`GizmoReach`).
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/* The plane tool is figure-model's now, by placement (`figurePlaneAlignChanges`); these stay the screen's. */
|
|
18
|
+
export type AlignTool = 'centre-x' | 'centre-z' | 'snap'
|
|
18
19
|
|
|
19
20
|
const AXES = ['x', 'y', 'z'] as const
|
|
20
21
|
|
|
@@ -50,8 +51,6 @@ export function alignChanges(asset: FigureAsset, part: string, reach: GizmoReach
|
|
|
50
51
|
return [set(at('x'), value(at('x')) - box.centre.x)]
|
|
51
52
|
case 'centre-z':
|
|
52
53
|
return [set(at('z'), value(at('z')) - box.centre.z)]
|
|
53
|
-
case 'floor':
|
|
54
|
-
return [set(at('y'), value(at('y')) - (box.centre.y - box.extent.y / 2))]
|
|
55
54
|
case 'snap': {
|
|
56
55
|
const step = gridStepOf(asset)
|
|
57
56
|
const round = (n: number) => Math.round(n / step) * step
|
|
@@ -6,8 +6,9 @@ import { create, Model, niceGridStep } from '@hatiolab/things-scene'
|
|
|
6
6
|
import type { GizmoReach } from './gizmo.js'
|
|
7
7
|
import type { FigureRenderPart } from './renderer.js'
|
|
8
8
|
import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
|
|
9
|
-
import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './stage-model.js'
|
|
9
|
+
import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board, type ScaleAnchor } from './stage-model.js'
|
|
10
10
|
import { JOINT_FAN } from './joint-fan.js'
|
|
11
|
+
import { PREVIEW, TARGET_FACE } from './command-preview.js'
|
|
11
12
|
import { ownerOf } from './fields.js'
|
|
12
13
|
|
|
13
14
|
/*
|
|
@@ -81,6 +82,8 @@ export class FigureCanvas extends LitElement {
|
|
|
81
82
|
`
|
|
82
83
|
@property({ attribute: false }) parts: FigureRenderPart[] = []
|
|
83
84
|
@property({ attribute: false }) palette: Record<string, string> = {}
|
|
85
|
+
/** The face a size drag keeps, per part (figure-model's `figureScaleAnchorOf`), so the drag shows what is written. */
|
|
86
|
+
@property({ attribute: false }) anchors: Record<string, ScaleAnchor> = {}
|
|
84
87
|
/** Which figure is open. Another one stands the board up again. */
|
|
85
88
|
@property() context = ''
|
|
86
89
|
@property() selected = ''
|
|
@@ -93,8 +96,16 @@ export class FigureCanvas extends LitElement {
|
|
|
93
96
|
* would reach the editor as an edit.
|
|
94
97
|
*/
|
|
95
98
|
@property({ type: Boolean }) viewOnly = false
|
|
99
|
+
/** The editor shows a new figure's start card over the canvas: the canvas's own one-line hint gives way to it. */
|
|
100
|
+
@property({ type: Boolean }) startShown = false
|
|
96
101
|
/** Where the selected part turns and how far (`joint-fan`): shown, never picked, never saved. */
|
|
97
102
|
@property({ attribute: false }) fan: FigureRenderPart | null = null
|
|
103
|
+
/**
|
|
104
|
+
* A command seen before it is done (ADR-0097 §5.5): the bodies it would move, where they would stand, translucent,
|
|
105
|
+
* and the face they would be fastened to, lit. Shown, never picked, never saved; gone when the command is.
|
|
106
|
+
*/
|
|
107
|
+
@property({ attribute: false }) ghost: FigureRenderPart[] = []
|
|
108
|
+
@property({ attribute: false }) highlight: FigureRenderPart | null = null
|
|
98
109
|
|
|
99
110
|
@query('div[stage]') private stage!: HTMLDivElement
|
|
100
111
|
|
|
@@ -106,7 +117,7 @@ export class FigureCanvas extends LitElement {
|
|
|
106
117
|
private writing = false
|
|
107
118
|
|
|
108
119
|
render() {
|
|
109
|
-
return html`<div stage></div>${this.parts.length ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
|
|
120
|
+
return html`<div stage></div>${this.parts.length || this.startShown ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
firstUpdated() {
|
|
@@ -117,7 +128,7 @@ export class FigureCanvas extends LitElement {
|
|
|
117
128
|
|
|
118
129
|
protected updated(changed: PropertyValues) {
|
|
119
130
|
if (!this.hasUpdated) return
|
|
120
|
-
if (
|
|
131
|
+
if (['parts', 'palette', 'context', 'fan', 'anchors', 'ghost', 'highlight'].some(key => changed.has(key))) this.standUp()
|
|
121
132
|
if (changed.has('view')) this.applyView()
|
|
122
133
|
if (changed.has('selected')) this.showSelected()
|
|
123
134
|
}
|
|
@@ -210,10 +221,12 @@ export class FigureCanvas extends LitElement {
|
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
private components(): Record<string, unknown>[] {
|
|
213
|
-
const parts = this.parts.map(part => componentOf(part, this.palette, this.board
|
|
224
|
+
const parts = this.parts.map(part => componentOf(part, this.palette, this.board!, this.anchors[ownerOf(part.id)])).filter((c): c is Record<string, unknown> => !!c)
|
|
214
225
|
/* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
|
|
215
226
|
const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
|
|
216
|
-
|
|
227
|
+
const accent = this.palette['palette.accent'] ?? FAN_COLOUR
|
|
228
|
+
const shown = [...(fan ? [fan] : []), ...this.ghost.map(part => componentOf(part, { ...this.palette, [PREVIEW]: accent, [TARGET_FACE]: accent }, this.board!)), ...(this.highlight ? [componentOf(this.highlight, { ...this.palette, [TARGET_FACE]: accent }, this.board!)] : [])]
|
|
229
|
+
return [...parts, ...shown.filter((c): c is Record<string, unknown> => !!c).map(c => ({ ...c, capturable: false, locked: true }))]
|
|
217
230
|
}
|
|
218
231
|
|
|
219
232
|
/** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { FigureBodyMove, FigureClipLoss, FigureFaceArea, FigureRowReading } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
import type { FigureRenderPart } from './renderer.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would
|
|
7
|
+
* move, translucent where they would stand; the face they would meet, lit; and in words, how far and how each turns --
|
|
8
|
+
* the turn as figure-model gives it, one axis and one angle in the figure's frame.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/* The material tokens of what a command would do: the bodies where they would stand, and the face they would meet. */
|
|
12
|
+
export const PREVIEW = '__preview'
|
|
13
|
+
export const TARGET_FACE = '__target-face'
|
|
14
|
+
|
|
15
|
+
export type Say = (key: string, params?: Record<string, string>) => string
|
|
16
|
+
|
|
17
|
+
/** A preview as the editor holds it: bound to the figure, version and command it was worked out for. */
|
|
18
|
+
export interface HeldPreview {
|
|
19
|
+
context: string
|
|
20
|
+
revision: number
|
|
21
|
+
/** The command, as text: a preview for another command is not this one's. */
|
|
22
|
+
key: string
|
|
23
|
+
geometry?: unknown[]
|
|
24
|
+
moves?: FigureBodyMove[]
|
|
25
|
+
/** Bodies it would draw that are not drawn now: a row's copies. */
|
|
26
|
+
added?: string[]
|
|
27
|
+
/** The row it would make, read on the preview's figure. */
|
|
28
|
+
row?: FigureRowReading
|
|
29
|
+
clipsWouldGo?: FigureClipLoss[]
|
|
30
|
+
face?: FigureFaceArea
|
|
31
|
+
/** Whether a face was asked for and could not be shown. */
|
|
32
|
+
faceUnknown?: boolean
|
|
33
|
+
/** Why the command would be refused; nothing else is shown then. */
|
|
34
|
+
refused?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The moving bodies where they would stand, drawn translucent. The ones that stay are not drawn twice. */
|
|
38
|
+
export function ghostPartsOf(preview: HeldPreview | undefined): FigureRenderPart[] {
|
|
39
|
+
if (!preview?.geometry || !(preview.moves?.length || preview.added?.length)) return []
|
|
40
|
+
const moving = new Set([...(preview.moves ?? []).map(m => m.body), ...(preview.added ?? [])])
|
|
41
|
+
return (preview.geometry as FigureRenderPart[])
|
|
42
|
+
.filter(body => body && typeof body === 'object' && 'shape' in body && 'pose' in body && moving.has(body.id))
|
|
43
|
+
.map(body => ({ ...body, id: `${PREVIEW}:${body.id}`, material: { token: PREVIEW, transparent: true } }))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How thick the lit face is, and how far off the face it floats, so it neither hides nor flickers with it. */
|
|
47
|
+
const THICKNESS = 2
|
|
48
|
+
const LIFT = 1
|
|
49
|
+
|
|
50
|
+
/** The face the part would meet, lit: a thin plate over a flat face, a thin disc over a round one. */
|
|
51
|
+
export function faceHighlightOf(face: FigureFaceArea | undefined): FigureRenderPart | null {
|
|
52
|
+
if (!face) return null
|
|
53
|
+
/* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */
|
|
54
|
+
const r = [0, 1, 2].map(i => [face.u[i]!, face.normal[i]!, face.v[i]!])
|
|
55
|
+
const t = [0, 1, 2].map(i => face.centre[i]! + face.normal[i]! * (LIFT + THICKNESS / 2))
|
|
56
|
+
const shape: FigureRenderPart['shape'] = face.round
|
|
57
|
+
? { provider: 'cylinder', dimensions: { radius: face.size.u / 2, length: THICKNESS } }
|
|
58
|
+
: { provider: 'box', dimensions: { width: face.size.u, height: THICKNESS, depth: face.size.v } }
|
|
59
|
+
/* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */
|
|
60
|
+
return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mm = (v: number) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(Math.round(v * 10) / 10).toLocaleString()}`
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the command would do, in words: the part's own move and turn, what goes with it, the clips it would stop, and
|
|
67
|
+
* why nothing can be shown when the command would be refused.
|
|
68
|
+
*/
|
|
69
|
+
export function previewWordsOf(preview: HeldPreview | undefined, part: string, say: Say): string[] {
|
|
70
|
+
if (!preview) return []
|
|
71
|
+
if (preview.refused) return [say('figure.text.preview-refused', { reason: preview.refused })]
|
|
72
|
+
/* A row: what it would be, read as drawn; its part does not move, it is copied. */
|
|
73
|
+
if (preview.row) return [...rowWordsOf(preview.row, say), ...(preview.clipsWouldGo?.length ? [say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') })] : [])]
|
|
74
|
+
const words: string[] = []
|
|
75
|
+
const own = preview.moves?.find(m => m.body === part)
|
|
76
|
+
if (!own) words.push(say('figure.text.preview-stays'))
|
|
77
|
+
else {
|
|
78
|
+
const axes = (['x', 'y', 'z'] as const).map((axis, i) => [axis, own.translate[i]!] as const).filter(([, v]) => Math.abs(v) >= 0.05)
|
|
79
|
+
if (axes.length) words.push(say('figure.text.preview-moves', { by: axes.map(([axis, v]) => `${axis.toUpperCase()} ${mm(v)}`).join(' · ') }))
|
|
80
|
+
if (own.turn) {
|
|
81
|
+
/* Signed by the right hand about the axis, as the figure's frame has it. */
|
|
82
|
+
const degrees = mm(own.turn.degrees)
|
|
83
|
+
words.push(
|
|
84
|
+
typeof own.turn.axis === 'string'
|
|
85
|
+
? say('figure.text.preview-turns-named', { axis: own.turn.axis.toUpperCase(), degrees })
|
|
86
|
+
: say('figure.text.preview-turns-slanted', { axis: own.turn.axis.map(v => v.toFixed(2)).join(', '), degrees })
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/* What goes with it: parts fastened to it, each row once, with its count. */
|
|
91
|
+
const others = new Map<string, number>()
|
|
92
|
+
for (const m of preview.moves ?? []) if (m.part !== part) others.set(m.part, (others.get(m.part) ?? 0) + 1)
|
|
93
|
+
if (others.size)
|
|
94
|
+
words.push(say('figure.text.preview-carries', { parts: [...others].map(([name, n]) => (n > 1 ? say('figure.text.preview-copies', { part: name, n: String(n) }) : name)).join(', ') }))
|
|
95
|
+
if (preview.clipsWouldGo?.length) words.push(say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') }))
|
|
96
|
+
if (preview.faceUnknown) words.push(say('figure.text.preview-face-unknown'))
|
|
97
|
+
return words
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A row in words, as figure-model reads it from what is drawn: how many, how far apart, what is left at the ends, what rides it. */
|
|
101
|
+
export function rowWordsOf(row: FigureRowReading | undefined, say: Say): string[] {
|
|
102
|
+
if (!row) return []
|
|
103
|
+
const words = [
|
|
104
|
+
row.pitch !== undefined
|
|
105
|
+
? say('figure.text.row-count-pitch', { part: row.part, count: String(row.count), pitch: String(row.pitch), axis: row.axis.toUpperCase() })
|
|
106
|
+
: say('figure.text.row-count-uneven', { part: row.part, count: String(row.count), axis: row.axis.toUpperCase() })
|
|
107
|
+
]
|
|
108
|
+
if (!row.margins) words.push(say('figure.text.row-ends-unknown'))
|
|
109
|
+
else if (row.centred) words.push(say('figure.text.row-ends-centred', { mm: String(row.margins.low) }))
|
|
110
|
+
else words.push(say('figure.text.row-ends', { low: String(row.margins.low), high: String(row.margins.high) }))
|
|
111
|
+
if (row.companions.length) words.push(say('figure.text.row-companions', { parts: row.companions.map(c => say('figure.text.preview-copies', { part: c.part, n: String(c.count) })).join(', ') }))
|
|
112
|
+
return words
|
|
113
|
+
}
|