@things-factory/figure-ui 10.1.200 → 10.1.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/client/modeller/editor.ts +7 -3
  2. package/client/modeller/fasten-form.ts +18 -35
  3. package/client/modeller/fasten-geometry.ts +67 -115
  4. package/client/modeller/figure-fasten-dialog.ts +4 -12
  5. package/client/modeller/figure-links.ts +6 -7
  6. package/client/modeller/joint-drag.ts +5 -5
  7. package/client/modeller/links.ts +35 -99
  8. package/client/modeller/refusals.ts +2 -6
  9. package/dist-client/modeller/editor.js +7 -2
  10. package/dist-client/modeller/editor.js.map +1 -1
  11. package/dist-client/modeller/fasten-form.d.ts +5 -8
  12. package/dist-client/modeller/fasten-form.js +16 -28
  13. package/dist-client/modeller/fasten-form.js.map +1 -1
  14. package/dist-client/modeller/fasten-geometry.d.ts +20 -19
  15. package/dist-client/modeller/fasten-geometry.js +54 -113
  16. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  17. package/dist-client/modeller/figure-fasten-dialog.js +4 -12
  18. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -1
  20. package/dist-client/modeller/figure-links.js +6 -7
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/joint-drag.js +5 -5
  23. package/dist-client/modeller/joint-drag.js.map +1 -1
  24. package/dist-client/modeller/links.d.ts +19 -26
  25. package/dist-client/modeller/links.js +36 -94
  26. package/dist-client/modeller/links.js.map +1 -1
  27. package/dist-client/modeller/refusals.d.ts +1 -1
  28. package/dist-client/modeller/refusals.js +2 -6
  29. package/dist-client/modeller/refusals.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +6 -6
  32. package/test/ai-proposal.test.ts +1 -1
  33. package/test/browser/acceptance-page.js +11 -11
  34. package/test/browser/detail-page.js +1 -1
  35. package/test/browser/detail-tabs-page.js +1 -1
  36. package/test/browser/editing-page.js +1 -1
  37. package/test/browser/fix-alignment.check.mjs +3 -3
  38. package/test/browser/links.check.mjs +20 -8
  39. package/test/browser/modeller-page.js +3 -3
  40. package/test/browser/part-ids.check.mjs +2 -2
  41. package/test/browser/parts-page.js +2 -2
  42. package/test/browser/product-page.check.mjs +3 -3
  43. package/test/browser/product-page.js +1 -1
  44. package/test/browser/row-preview.check.mjs +2 -2
  45. package/test/browser/screen-page.js +3 -3
  46. package/test/browser/section-page.js +3 -3
  47. package/test/browser/section.check.mjs +4 -4
  48. package/test/clip-ai-proposal.test.ts +1 -1
  49. package/test/clip-consent.test.ts +1 -1
  50. package/test/command-preview.test.ts +8 -8
  51. package/test/face-mapping.test.ts +2 -2
  52. package/test/fasten-drawing.test.ts +14 -2
  53. package/test/fasten-form.test.ts +11 -3
  54. package/test/fasten-geometry.test.ts +22 -31
  55. package/test/fix-alignment.test.ts +16 -4
  56. package/test/gizmo.test.ts +2 -2
  57. package/test/joint-drag.test.ts +13 -2
  58. package/test/joint-fan.test.ts +1 -1
  59. package/test/joint-frames.test.ts +45 -23
  60. package/test/joints.ts +1 -1
  61. package/test/links.test.ts +68 -68
  62. package/test/live.test.ts +2 -2
  63. package/test/preview-size.test.ts +1 -1
  64. package/test/refusals.test.ts +18 -20
  65. package/test/resize-with-placement.test.ts +6 -6
  66. package/test/revision.test.ts +4 -5
  67. package/test/section-panel.test.ts +1 -1
  68. package/translations/en.json +9 -11
  69. package/translations/es.json +9 -11
  70. package/translations/ja.json +9 -11
  71. package/translations/ko.json +9 -11
  72. package/translations/ms.json +9 -11
  73. package/translations/zh.json +9 -11
@@ -1442,12 +1442,15 @@ export class FigureEditor extends LitElement {
1442
1442
  return derivedOf(shown, `overhang|${part}|${i18next.language}`, () => {
1443
1443
  const asset = shown.asset
1444
1444
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1445
- const where = (o: FigureFaceOverhang) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
1445
+ const where = (o: FigureFaceOverhang & { on: { part: string; face: string } }) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
1446
+ /* On another part's face: the plane's edges are the figure's room, said where the room is. */
1447
+ const onPart = (o: FigureFaceOverhang | undefined): o is FigureFaceOverhang & { on: { part: string; face: string } } => !!o && o.on !== 'plane'
1446
1448
  const sides = (o: FigureFaceOverhang) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ')
1447
1449
  const words: string[] = []
1448
1450
  const note: string[] = []
1449
1451
  try {
1450
- const own = figureFaceOverhangOf(asset, part, shown.inputs)
1452
+ const found = figureFaceOverhangOf(asset, part, shown.inputs)
1453
+ const own = onPart(found) ? found : undefined
1451
1454
  if (own?.state === 'larger') {
1452
1455
  note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }))
1453
1456
  if (own.lopsided.length) note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }))
@@ -1455,8 +1458,9 @@ export class FigureEditor extends LitElement {
1455
1458
  if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))
1456
1459
  else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))
1457
1460
  else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))
1461
+ if (own?.contact === 'off') words.push(say('figure.text.overhang-off', where(own)))
1458
1462
  const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => figureFaceOverhangOf(asset, j.body1.part, shown.inputs))
1459
- const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible')
1463
+ const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible' || o?.contact === 'off')
1460
1464
  if (past.length) words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }))
1461
1465
  } catch {
1462
1466
  /* Not read: nothing said rather than a guess. */
@@ -1,7 +1,7 @@
1
1
  import { FIGURE_FACES } from '@hatiolab/figure-model'
2
2
  import type { FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
3
3
 
4
- import { faceViewOf, modeOf, myFaceFor, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
4
+ import { faceViewOf, 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,
@@ -12,7 +12,7 @@ import {
12
12
  fastenGapOf,
13
13
  hangsFrom,
14
14
  heldBy,
15
- ownFacesOf,
15
+ mountingPlaneOf,
16
16
  placeKeptOf,
17
17
  type Alignment,
18
18
  type Axis
@@ -20,7 +20,7 @@ import {
20
20
  import type { FigureRevision } from './revision.js'
21
21
 
22
22
  /*
23
- * A fastening as it is chosen, before it is done: what, which face, how, where across the face. The fastening window
23
+ * A fastening as it is chosen, before it is done: to what, which face, how, where across the face. The fastening window
24
24
  * (`figure-fasten-dialog.ts`) holds one and changes it as the author chooses; the attach command is worked out from it
25
25
  * only here, so the window, its preview and the panel's 「정렬 고치기」 send the same command for the same choice.
26
26
  */
@@ -28,29 +28,26 @@ import type { FigureRevision } from './revision.js'
28
28
  const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
29
29
 
30
30
  export interface FastenForm {
31
- face: FigureFace | 'centre'
32
31
  toPlane: boolean
33
32
  target: string
34
33
  targetFace: FigureFace
35
- facing: 'meet' | 'flush'
34
+ /** How it meets the face: its near end on it, its far end on it (flush), or its middle (ADR-0140 3.11). */
35
+ facing: FastenMode
36
36
  gap: number
37
37
  /** The record's gap when it is worked out (a length that follows something): kept until a number is typed. */
38
38
  gapKept: unknown
39
- twist: number
40
39
  align: Partial<Record<Axis, Alignment>>
41
40
  alignMm: Partial<Record<Axis, number>>
42
41
  alignInput: Partial<Record<Axis, string>>
43
42
  }
44
43
 
45
44
  export const freshFasten = (): FastenForm => ({
46
- face: 'bottom',
47
45
  toPlane: false,
48
46
  target: '',
49
47
  targetFace: 'top',
50
48
  facing: 'meet',
51
49
  gap: 0,
52
50
  gapKept: undefined,
53
- twist: 0,
54
51
  align: {},
55
52
  alignMm: {},
56
53
  alignInput: {}
@@ -97,7 +94,7 @@ export function aimFasten(
97
94
  : f.target
98
95
  ? { part: f.target, face: targetFace }
99
96
  : undefined
100
- return { ...f, ...(aim ? placeKeptOf(revision, part, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) }
97
+ return { ...f, ...(aim ? placeKeptOf(revision, part, aim) : { align: {}, alignMm: {}, alignInput: {} }) }
101
98
  }
102
99
 
103
100
  /** A change the drawing asks: a face, a way, a gap or a place across the face. */
@@ -109,12 +106,7 @@ export function fastenChanged(
109
106
  ): FastenForm {
110
107
  const f = form
111
108
  if ('targetFace' in change) return aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace })
112
- if ('mode' in change)
113
- return {
114
- ...f,
115
- face: change.mode === 'centre' ? 'centre' : f.face === 'centre' ? 'bottom' : f.face,
116
- facing: change.mode === 'flush' ? 'flush' : 'meet'
117
- }
109
+ if ('mode' in change) return { ...f, facing: change.mode }
118
110
  if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined }
119
111
  return {
120
112
  ...f,
@@ -123,15 +115,13 @@ export function fastenChanged(
123
115
  }
124
116
  }
125
117
 
126
- /** What a form comes to: the face aimed at, the part's own face, the way, the drawing of the face and the command. */
118
+ /** What a form comes to: the face aimed at, the way, the drawing of the face and the command. */
127
119
  export interface FastenPlan {
128
120
  /** Nothing is aimed at until a part or the plane is chosen. */
129
121
  aim?: FastenTarget
130
122
  theirFaces: string[]
131
123
  targetFace: FigureFace
132
124
  mode: FastenMode
133
- /** The part's own face: not chosen, the target's face and the way give it (user 2026-10-01; the kernel takes no other). */
134
- myFace: FigureFace | 'centre'
135
125
  view?: FaceView
136
126
  /** The axes across the face asked in rows: where the drawing cannot set the place. */
137
127
  alignRows: { axis: Axis; edges: boolean }[]
@@ -142,7 +132,6 @@ export interface FastenPlan {
142
132
 
143
133
  export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
144
134
  const asset = revision.asset
145
- const mineFaces = ownFacesOf(asset, part, f.toPlane)
146
135
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
147
136
  const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
148
137
  const aim: FastenTarget | undefined = f.toPlane
@@ -150,20 +139,17 @@ export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenFo
150
139
  : f.target
151
140
  ? { part: f.target, face: targetFace }
152
141
  : undefined
153
- const mode: FastenMode = f.toPlane ? 'meet' : modeOf(f.face, f.facing)
154
- const derived = aim ? myFaceFor(revision, part, aim, mode) : undefined
155
- const myFace = derived ?? (mineFaces.includes(f.face) ? f.face : mineFaces[0]!)
156
- if (!aim) return { theirFaces, targetFace, mode, myFace, alignRows: [] }
157
- const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
158
- const view = faceViewOf(revision, part, aim)
142
+ /* The plane takes a part standing on it or hanging below it, a wall only one standing out of it (ADR-0140 3.11). */
143
+ const mode: FastenMode = f.toPlane ? (mountingPlaneOf(asset).facings.includes(f.facing) ? f.facing : 'meet') : f.facing
144
+ if (!aim) return { theirFaces, targetFace, mode, alignRows: [] }
145
+ const align = alignOf(asset, aim, f.align, f.alignMm, f.alignInput)
146
+ const view = faceViewOf(revision, part, aim, mode)
159
147
  const action = {
160
148
  kind: 'attach',
161
149
  part,
162
- face: myFace,
163
150
  to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
164
- ...(f.toPlane ? {} : { facing: f.facing }),
151
+ ...(f.toPlane ? (mode === 'flush' ? { facing: mode } : {}) : { facing: mode }),
165
152
  ...fastenGapOf(f),
166
- twist: f.twist,
167
153
  ...(align ? { align } : {}),
168
154
  replace: !!heldBy(asset, part)
169
155
  } as FigureAuthoringAction
@@ -172,9 +158,8 @@ export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenFo
172
158
  theirFaces,
173
159
  targetFace,
174
160
  mode,
175
- myFace,
176
161
  view,
177
- alignRows: alignRowsOf(asset, part, aim, view, !!f.twist, f.alignInput),
162
+ alignRows: alignRowsOf(asset, aim, view, f.alignInput),
178
163
  action,
179
164
  ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } })
180
165
  }
@@ -182,16 +167,15 @@ export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenFo
182
167
 
183
168
  type Say = (key: string, params?: Record<string, string>) => string
184
169
 
185
- /** 「lid 의 아랫면을 bin 의 윗면에 맞닿게」: what the fastening is, in one line, the part's own face said as a result. */
170
+ /** 「bin의 윗면에 올려놓기」: what the fastening is, in one line -- the part is the one the window is open on, not named again. */
186
171
  export function fastenSentenceOf(
187
- part: string,
188
172
  form: FastenForm,
189
173
  plan: FastenPlan,
190
174
  planeWord: string,
191
175
  say: Say
192
176
  ): string {
193
177
  const to = form.toPlane ? planeWord : `${form.target}의 ${say('figure.label.face-' + plan.targetFace)}`
194
- return say(`figure.text.fasten-sentence-${plan.mode}`, { part, face: say('figure.label.face-' + plan.myFace), to })
178
+ return say(`figure.text.fasten-sentence-${plan.mode}`, { to })
195
179
  }
196
180
 
197
181
  /** What the folded details hold that is not their default, said while folded; nothing for the default. */
@@ -201,7 +185,6 @@ export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
201
185
  ? [say('figure.text.fasten-summary-gap-worked-out')]
202
186
  : form.gap
203
187
  ? [say('figure.text.fasten-summary-gap', { mm: String(form.gap) })]
204
- : []),
205
- ...(form.twist ? [say('figure.text.fasten-summary-twist', { degrees: String(form.twist) })] : [])
188
+ : [])
206
189
  ]
207
190
  }
@@ -1,108 +1,67 @@
1
- import { figurePartFaceOf } from '@hatiolab/figure-model'
2
- import type { FigureAsset, FigureFace } from '@hatiolab/figure-model'
1
+ import { figureFaceInsideAlignOf, figureFaceMeasureOf, figureFaceOverhangAt, figureFastenPlaceOf } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureFaceMeasure, FigureFacing, FigureFastenAlignment } from '@hatiolab/figure-model'
3
3
 
4
- import { boxOf } from './align.js'
5
- import { alignAxesOf, facesOf, faceAxisOf, hasInstanceSize, mountingPlaneOf, type Alignment } from './links.js'
4
+ import { alignAxesOf, type Alignment } from './links.js'
6
5
  import type { FigureRevision } from './revision.js'
7
6
 
8
7
  /*
9
8
  * What the 붙이기 drawing reads (user 2026-10-01): the face a part is fastened to, seen from above, and where the part
10
- * lies on it. A fastening is chosen by the target's face and how (올려놓기 · 같은 면에 맞추기 · 가운데를 면에); the part's
11
- * own face follows from those -- the kernel takes no other pairing (figure-model `attach`: meet is the facing face,
12
- * flush the same one, a face along another axis would need a turn).
9
+ * lies on it. A fastening is chosen by the target's face and how (올려놓기 · 같은 면에 맞추기 · 가운데를 면에); the part
10
+ * meets it by its real shape in the pose it has, by no face of its own (ADR-0140 3.2). What is measured -- the face,
11
+ * the part's extent across it, how far it comes past and whether what touches is on it -- is the kernel's
12
+ * (`figureFaceMeasureOf`, 3.8); this only draws it.
13
13
  */
14
14
 
15
15
  type Axis = 'x' | 'y' | 'z'
16
- const AXES: Axis[] = ['x', 'y', 'z']
17
- export type FastenMode = 'meet' | 'flush' | 'centre'
16
+ export type FastenMode = FigureFacing
18
17
  export type FastenTarget = { part: string; face: string } | 'plane'
19
18
 
20
- const OPPOSITE: Record<FigureFace, FigureFace> = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' }
21
- const NORMAL: Record<FigureFace, [Axis, 1 | -1]> = { left: ['x', -1], right: ['x', 1], bottom: ['y', -1], top: ['y', 1], back: ['z', -1], front: ['z', 1] }
22
-
23
- /** The world direction a part's face looks along now, as drawn, turns and all; by its name when the face's outline is not read. */
24
- function directionOf(revision: FigureRevision, part: string, face: string): [Axis, 1 | -1] | undefined {
25
- const area = (() => {
26
- try {
27
- return figurePartFaceOf(revision.asset, part, face, revision.inputs)
28
- } catch {
29
- return undefined
30
- }
31
- })()
32
- if (area) {
33
- const k = area.normal.findIndex(v => Math.abs(v) > 0.5)
34
- if (k >= 0) return [AXES[k]!, area.normal[k]! > 0 ? 1 : -1]
35
- }
36
- return face in NORMAL ? NORMAL[face as FigureFace] : undefined
37
- }
38
-
39
- /**
40
- * The part's own face a fastening takes: the one looking against the target's face (올려놓기), with it (같은 면), or its
41
- * centre. Read from where each face looks now, so a part standing turned offers the face that is really there.
42
- * Undefined when the part has no such face.
43
- */
44
- export function myFaceFor(revision: FigureRevision, part: string, target: FastenTarget, mode: FastenMode): FigureFace | 'centre' | undefined {
45
- const asset = revision.asset
46
- if (mode === 'centre') return target === 'plane' ? undefined : 'centre'
47
- const want: [Axis, 1 | -1] | undefined =
48
- target === 'plane'
49
- ? /* The plane is met from above on a floor, from in front on a wall. */ [mountingPlaneOf(asset).normal, -1]
50
- : (() => {
51
- const d = directionOf(revision, target.part, target.face)
52
- return d && [d[0], (mode === 'meet' ? -d[1] : d[1]) as 1 | -1]
53
- })()
54
- if (!want) return undefined
55
- const mine = facesOf(asset, part).filter((f): f is FigureFace => f in NORMAL)
56
- const hit = mine.find(f => {
57
- const d = directionOf(revision, part, f)
58
- return d && d[0] === want[0] && d[1] === want[1]
59
- })
60
- return hit ?? (target !== 'plane' && mode === 'meet' && target.face in OPPOSITE ? OPPOSITE[target.face as FigureFace] : undefined)
61
- }
62
-
63
- /** Which way the fastening is, read back from the part's face and facing. */
64
- export const modeOf = (face: string, facing: 'meet' | 'flush'): FastenMode => (face === 'centre' ? 'centre' : facing)
65
-
66
19
  /**
67
- * The face seen from above: its two axes across (as the form's alignment names them), the face's size on each -- none
68
- * for a plane with no declared size -- the part's footprint on each, and whether its edges can be lined up with.
20
+ * 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
+ * fastening lines up to it -- none for a plane with no declared size -- the part's real extent on each, whether its
22
+ * edges can be lined up with, and the kernel's measure the drawing asks again as the part is moved.
69
23
  */
70
24
  export interface FaceView {
71
25
  axes: [Axis, Axis]
72
26
  face: Partial<Record<Axis, number>>
73
27
  part: Record<Axis, number>
74
28
  edges: Record<Axis, boolean>
29
+ measure: FigureFaceMeasure
75
30
  }
76
31
 
77
- export function faceViewOf(revision: FigureRevision, part: string, target: FastenTarget): FaceView | undefined {
78
- const asset = revision.asset
79
- const across = alignAxesOf(asset, part, target)
80
- if (across.length !== 2) return undefined
81
- const box = boxOf(revision, part)
82
- if (!box) return undefined
83
- const axes = [across[0]!.axis, across[1]!.axis] as [Axis, Axis]
32
+ export function faceViewOf(revision: FigureRevision, part: string, target: FastenTarget, facing: FastenMode): FaceView | undefined {
33
+ const measure = (() => {
34
+ try {
35
+ return figureFaceMeasureOf(revision.asset, part, revision.inputs, { on: target, facing })
36
+ } catch {
37
+ return undefined
38
+ }
39
+ })()
40
+ if (!measure) return undefined
41
+ const across = alignAxesOf(revision.asset, target)
84
42
  const face: Partial<Record<Axis, number>> = {}
85
- if (target === 'plane') {
86
- for (const a of axes) if (hasInstanceSize(asset, a)) face[a] = asset.designInputs[`size.${a}`]!
87
- } else {
88
- const theirs = boxOf(revision, target.part)
89
- const normal = faceAxisOf(asset, target.part, target.face)
90
- for (const a of axes) if (theirs && a !== normal) face[a] = theirs.extent[a]
43
+ for (const a of measure.axes) {
44
+ const e = measure.edges[a]
45
+ if (e) face[a] = e.hi - e.lo
91
46
  }
47
+ const extent = (a: Axis) => measure.extent[a].hi - measure.extent[a].lo
92
48
  return {
93
- axes,
49
+ axes: measure.axes,
94
50
  face,
95
- part: { x: box.extent.x, y: box.extent.y, z: box.extent.z },
96
- edges: Object.fromEntries(across.map(o => [o.axis, o.edges])) as Record<Axis, boolean>
51
+ part: { x: extent('x'), y: extent('y'), z: extent('z') },
52
+ edges: Object.fromEntries(measure.axes.map(a => [a, !!across.find(o => o.axis === a)?.edges])) as Record<Axis, boolean>,
53
+ measure
97
54
  }
98
55
  }
99
56
 
100
- /** Where the part's centre is from the face's centre along one axis, for the way it is aligned there. */
57
+ /**
58
+ * Where the part's centre is from the face's centre along one axis, for the way it is aligned there: the kernel's
59
+ * `figureFastenPlaceOf` (ADR-0140 D2), the form's distances -- typed, or a share of the size read as millimetres -- as
60
+ * a distance, a position field's as the centre.
61
+ */
101
62
  export function offsetOf(how: Alignment | undefined, mm: number | undefined, face: number | undefined, part: number): number {
102
- if (how === 'min' && face !== undefined) return -(face - part) / 2
103
- if (how === 'max' && face !== undefined) return (face - part) / 2
104
- if (how === 'mm' || how === 'share') return mm ?? 0
105
- return 0
63
+ const align = how === 'mm' || how === 'share' ? { mm: mm ?? 0 } : how === 'min' || how === 'max' || how === 'centre' ? how : undefined
64
+ return figureFastenPlaceOf({ align: { x: align }, face: { x: face }, part: { x: part } }).x!
106
65
  }
107
66
 
108
67
  /**
@@ -125,56 +84,49 @@ export function alignmentAt(d: number, face: number | undefined, part: number, e
125
84
  return { how: 'mm', mm: Math.round(d) }
126
85
  }
127
86
 
87
+ /** The form's alignment on each axis across the face, in the kernel's words: a typed or shared distance as millimetres. */
88
+ function kernelAlignOf(view: FaceView, align: Partial<Record<Axis, Alignment>>, alignMm: Partial<Record<Axis, number>>): Partial<Record<Axis, FigureFastenAlignment>> {
89
+ const out: Partial<Record<Axis, FigureFastenAlignment>> = {}
90
+ for (const a of view.axes) {
91
+ const how = align[a] ?? 'centre'
92
+ out[a] = how === 'mm' || how === 'share' ? { mm: alignMm[a] ?? 0 } : how === 'min' || how === 'max' ? how : 'centre'
93
+ }
94
+ return out
95
+ }
96
+
128
97
  /**
129
- * The alignment that brings the footprint inside the face, for 정렬·간격 고치기: on each axis it comes past an edge, the
130
- * least move -- to that edge from inside (`min` / `max`, or that distance where edges are not offered) -- or the centre
131
- * when the part is wider than the face and cannot be inside. `inside` when nothing of it is past an edge: what is out
132
- * is something fastened on it. `unmeasured` when the face has no size to measure against.
98
+ * The alignment that brings the part inside the face, for 정렬·간격 고치기: the kernel's least move
99
+ * (`figureFaceInsideAlignOf`), in the form's words. `inside` when nothing of it is past an edge: what is out is something
100
+ * fastened on it. `unmeasured` when the face has no size to measure against.
133
101
  */
134
102
  export function insideAlignmentOf(
135
103
  view: FaceView | undefined,
136
104
  align: Partial<Record<Axis, Alignment>>,
137
105
  alignMm: Partial<Record<Axis, number>>
138
106
  ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; moved: Axis[] } | { cannot: 'inside' | 'unmeasured' } {
139
- if (!view || view.axes.some(a => view.face[a] === undefined)) return { cannot: 'unmeasured' }
140
- const next = { align: { ...align }, alignMm: { ...alignMm }, moved: [] as Axis[] }
141
- for (const a of view.axes) {
142
- const face = view.face[a]!,
143
- part = view.part[a]
144
- const at = offsetOf(align[a], alignMm[a], face, part)
145
- const low = -face / 2 - (at - part / 2) > 0.5,
146
- high = at + part / 2 - face / 2 > 0.5
147
- if (!low && !high) continue
148
- next.moved.push(a)
149
- if (part > face) {
150
- next.align[a] = 'centre'
151
- continue
152
- }
153
- const edge = ((low ? -1 : 1) * (face - part)) / 2
154
- if (view.edges[a]) next.align[a] = low ? 'min' : 'max'
155
- else ((next.align[a] = 'mm'), (next.alignMm[a] = Math.round(edge)))
107
+ if (!view) return { cannot: 'unmeasured' }
108
+ const fix = figureFaceInsideAlignOf(view.measure, kernelAlignOf(view, align, alignMm), view.edges)
109
+ if ('cannot' in fix) return fix
110
+ const next = { align: { ...align }, alignMm: { ...alignMm }, moved: fix.moved as Axis[] }
111
+ for (const a of fix.moved as Axis[]) {
112
+ const how = fix.align[a]
113
+ if (how && typeof how === 'object') ((next.align[a] = 'mm'), (next.alignMm[a] = how.mm))
114
+ else if (how) next.align[a] = how
156
115
  }
157
- return next.moved.length ? next : { cannot: 'inside' }
116
+ return next
158
117
  }
159
118
 
160
- /** How far the footprint comes past the face's edges, per side, and whether it touches the face at all. */
161
- export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching: boolean; sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> } {
162
- const sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> = []
163
- let touching = true
164
- for (const a of view.axes) {
165
- const face = view.face[a]
166
- if (face === undefined) continue
167
- const lo = at[a] - view.part[a] / 2,
168
- hi = at[a] + view.part[a] / 2
169
- if (lo >= face / 2 || hi <= -face / 2) touching = false
170
- if (-face / 2 - lo > 0.5) sides.push({ axis: a, side: 'low', mm: Math.round(-face / 2 - lo) })
171
- if (hi - face / 2 > 0.5) sides.push({ axis: a, side: 'high', mm: Math.round(hi - face / 2) })
172
- }
173
- return { touching, sides }
119
+ /**
120
+ * How far the part comes past the face's edges, per side, whether any of it lies over the face, and whether what
121
+ * touches the face is on it -- with its middle `at` from the face's middle: the kernel's (`figureFaceOverhangAt`).
122
+ */
123
+ export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching: boolean; contact: 'on' | 'off' | 'unknown'; sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> } {
124
+ const o = figureFaceOverhangAt(view.measure, Object.fromEntries(view.axes.map(a => [a, { mm: at[a] }])))
125
+ return { touching: o.over, contact: o.contact, sides: o.sides as Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> }
174
126
  }
175
127
 
176
128
  /** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
177
- export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || ('face' in j.body1 && j.body1.face === 'centre')))
129
+ export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || j.origin?.facing === 'centre'))
178
130
 
179
131
  /**
180
132
  * How the face lies on the screen: X across when it is one of the face's axes, else Z; the other down the screen -- up
@@ -23,7 +23,7 @@ import {
23
23
  fastenTargetsOf,
24
24
  type FastenForm
25
25
  } from './fasten-form.js'
26
- import { fieldWordsOf, hasInstanceSize, heldBy, type Alignment, type Axis } from './links.js'
26
+ import { fieldWordsOf, hasInstanceSize, heldBy, mountingPlaneOf, type Alignment, type Axis } from './links.js'
27
27
  import type { FigureRevision } from './revision.js'
28
28
 
29
29
  /** The target list's entry for the plane the figure stands by. */
@@ -178,7 +178,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
178
178
  const plan = fastenPlanOf(revision, this.partId, f)
179
179
  const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'))
180
180
  const modes: FastenMode[] = f.toPlane
181
- ? ['meet']
181
+ ? mountingPlaneOf(asset).facings
182
182
  : ['meet', 'flush', ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])]
183
183
  const detail = fastenDetailWordsOf(f, (key, params) => i18next.t(key, params))
184
184
  const drawing = (show: 'face' | 'how') =>
@@ -202,7 +202,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
202
202
  ${
203
203
  plan.aim
204
204
  ? html`<p fasten-sentence role="status">
205
- ${fastenSentenceOf(this.partId, f, plan, planeWord, (key, params) => i18next.t(key, params))}
205
+ ${fastenSentenceOf(f, plan, planeWord, (key, params) => i18next.t(key, params))}
206
206
  </p>`
207
207
  : html`<p hint fasten-sentence>${i18next.t('figure.text.fasten-target-first')}</p>`
208
208
  }
@@ -215,7 +215,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
215
215
  ? html`<div question="face">
216
216
  <h4>
217
217
  ${
218
- /* The face asked is the target's, named: the part's own face follows from it. */ f.toPlane
218
+ /* The face asked is the target's, named: the part meets it by its real shape, by no face of its own. */ f.toPlane
219
219
  ? i18next.t('figure.label.fasten-face-on-plane', { plane: planeWord })
220
220
  : i18next.t('figure.label.fasten-face-on-part', { part: f.target })
221
221
  }
@@ -246,14 +246,6 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
246
246
  placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
247
247
  @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined })}
248
248
  />
249
- <span>${i18next.t('figure.label.twist')}</span>
250
- <input
251
- twist
252
- type="number"
253
- step="any"
254
- .value=${String(f.twist)}
255
- @change=${(e: Event) => this.set({ twist: Number((e.target as HTMLInputElement).value) || 0 })}
256
- />
257
249
  </div>
258
250
  </details>
259
251
  </div>
@@ -463,7 +463,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
463
463
  const revision = this.revision
464
464
  if (!revision) return
465
465
  const form = fastenFormOf(revision, this.partId)
466
- /* Whether it is out is the kernel's measure (a round face is not measured there); the drawing's sizes say how far to move. */
466
+ /* Whether it is out, and the least move that brings it in, are the kernel's measure (ADR-0140 3.8). */
467
467
  const own = figureFaceOverhangOf(revision.asset, this.partId, revision.inputs)
468
468
  const fixed =
469
469
  own?.state === 'unknown'
@@ -486,21 +486,20 @@ export class FigureLinks extends localize(i18next)(LitElement) {
486
486
  }
487
487
 
488
488
  /**
489
- * What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
489
+ * What holds the part, said as its state -- 「바닥에 붙어 있음」 -- with the ways to change it: open the fastening,
490
490
  * or take it off. Right after "follow size, attached" this is the result, not a refusal (designer 2026-09-27).
491
491
  */
492
492
  private heldWords(asset: FigureAsset) {
493
493
  const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
494
- const face = joint ? i18next.t(endPlaceKeyOf(joint.body1)) : ''
495
494
  const gap = joint?.origin?.gap ?? 0
496
495
  const place = i18next.t(`figure.label.resize-place-${asset.placement ?? 'floor'}`)
497
496
  const where = !joint
498
497
  ? i18next.t('figure.text.held-by-rule')
499
498
  : 'plane' in joint.body0
500
499
  ? typeof gap === 'number'
501
- ? i18next.t('figure.text.held-on-plane', { face, place, gap: String(gap) })
502
- : i18next.t('figure.text.held-on-plane-worked-out', { face, place })
503
- : i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(endPlaceKeyOf(joint.body0)) })
500
+ ? i18next.t('figure.text.held-on-plane', { place, gap: String(gap) })
501
+ : i18next.t('figure.text.held-on-plane-worked-out', { place })
502
+ : i18next.t('figure.text.held-on-part', { other: joint.body0.part, otherFace: i18next.t(endPlaceKeyOf(joint.body0)) })
504
503
  return where
505
504
  }
506
505
 
@@ -572,7 +571,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
572
571
  return html`
573
572
  ${overhang}
574
573
  <div fastened-summary>
575
- <p fasten-sentence>${fastenSentenceOf(this.partId, form, plan, planeWord, (key, params) => i18next.t(key, params))}</p>
574
+ <p fasten-sentence>${fastenSentenceOf(form, plan, planeWord, (key, params) => i18next.t(key, params))}</p>
576
575
  <button act change-fastening ?disabled=${this.locked} @click=${() => this.openFastening()}>${i18next.t('figure.button.change-fastening')}</button>
577
576
  </div>
578
577
  ${this.renderDetachWords()}
@@ -39,8 +39,8 @@ const round = (v: number, step: number) => Number((Math.round(v / step) * step).
39
39
  const rotateMatrix = (j: FigureJoint) => eulerMatrixOrder(AXES.map(a => j.origin?.parentRotate?.[a] ?? 0), 'ZYX')
40
40
  const eulerMatrixOrder = (deg: number[], order: THREE.EulerOrder) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), order))
41
41
 
42
- /** The part's own quarter turn in the joint frame, as the record says it (`origin.childRotate`). */
43
- const turnMatrix = (j: FigureJoint) => eulerMatrix(AXES.map(a => j.origin?.childRotate?.[a] ?? 0))
42
+ /** The part's own pose on its end, any angle, as the record says it (`origin.childRotate`, Rz·Ry·Rx, ADR-0140 3.11). */
43
+ const turnMatrix = (j: FigureJoint) => eulerMatrixOrder(AXES.map(a => j.origin?.childRotate?.[a] ?? 0), 'ZYX')
44
44
 
45
45
  /** The joint frame's rotation in the figure: the part's rotation now, less its own turn and less the joint's. */
46
46
  function jointFrameRotation(j: FigureJoint, part: FigureRenderPart, angle: number): THREE.Matrix4 {
@@ -113,9 +113,9 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
113
113
  }
114
114
  return { kind: 'joint', joint: { ...j, origin: { ...origin, offset } } as FigureJoint }
115
115
  }
116
- const normal: Axis = 'plane' in j.body0 ? 'y' : FACE_AXIS[j.body0.face]!
117
- /* Which way a larger gap moves the part: out of the face it meets (on the plane: up when it stands, down when it hangs). */
118
- const outward = 'plane' in j.body0 ? ('face' in j.body1 && j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
116
+ const normal: Axis = 'plane' in j.body0 ? (asset.placement === 'wall' ? 'z' : 'y') : FACE_AXIS[j.body0.face]!
117
+ /* Which way a larger gap moves the part: out of the face it meets or sits by its middle on, into it when flush with it (ADR-0140 3.11). */
118
+ const outward = ((j.origin?.facing ?? 'meet') === 'flush' ? -1 : 1) * ('plane' in j.body0 ? 1 : FACE_SIGN[j.body0.face]!)
119
119
  const along = local[normal]
120
120
  if (Math.abs(along) >= 0.5) {
121
121
  const gap = origin.gap ?? 0