@things-factory/figure-ui 10.1.91 → 10.1.93

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 (56) hide show
  1. package/client/modeller/editor.ts +61 -90
  2. package/client/modeller/fasten-geometry.ts +152 -0
  3. package/client/modeller/fields.ts +8 -1
  4. package/client/modeller/figure-cavity-drawing.ts +259 -0
  5. package/client/modeller/figure-fasten-drawing.ts +406 -0
  6. package/client/modeller/figure-links.ts +71 -35
  7. package/client/modeller/figure-section-close-choice.ts +52 -0
  8. package/client/modeller/figure-section-editor.ts +58 -3
  9. package/client/modeller/figure-section-panel.ts +5 -0
  10. package/client/modeller/section-controls.ts +19 -0
  11. package/client/modeller/side-panel-styles.ts +4 -11
  12. package/dist-client/modeller/editor.d.ts +10 -4
  13. package/dist-client/modeller/editor.js +59 -88
  14. package/dist-client/modeller/editor.js.map +1 -1
  15. package/dist-client/modeller/fasten-geometry.d.ts +60 -0
  16. package/dist-client/modeller/fasten-geometry.js +138 -0
  17. package/dist-client/modeller/fasten-geometry.js.map +1 -0
  18. package/dist-client/modeller/fields.js +8 -1
  19. package/dist-client/modeller/fields.js.map +1 -1
  20. package/dist-client/modeller/figure-cavity-drawing.d.ts +48 -0
  21. package/dist-client/modeller/figure-cavity-drawing.js +253 -0
  22. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -0
  23. package/dist-client/modeller/figure-fasten-drawing.d.ts +74 -0
  24. package/dist-client/modeller/figure-fasten-drawing.js +452 -0
  25. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -0
  26. package/dist-client/modeller/figure-links.d.ts +7 -0
  27. package/dist-client/modeller/figure-links.js +70 -33
  28. package/dist-client/modeller/figure-links.js.map +1 -1
  29. package/dist-client/modeller/figure-section-close-choice.d.ts +17 -0
  30. package/dist-client/modeller/figure-section-close-choice.js +58 -0
  31. package/dist-client/modeller/figure-section-close-choice.js.map +1 -0
  32. package/dist-client/modeller/figure-section-editor.d.ts +11 -1
  33. package/dist-client/modeller/figure-section-editor.js +57 -3
  34. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  35. package/dist-client/modeller/figure-section-panel.d.ts +1 -0
  36. package/dist-client/modeller/figure-section-panel.js +5 -0
  37. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  38. package/dist-client/modeller/section-controls.d.ts +10 -0
  39. package/dist-client/modeller/section-controls.js +17 -0
  40. package/dist-client/modeller/section-controls.js.map +1 -1
  41. package/dist-client/modeller/side-panel-styles.js +4 -11
  42. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +2 -2
  45. package/test/browser/acceptance-page.js +10 -0
  46. package/test/cavity-drawing.test.ts +69 -0
  47. package/test/fasten-drawing.test.ts +45 -0
  48. package/test/fasten-geometry.test.ts +79 -0
  49. package/test/layout.test.ts +19 -4
  50. package/test/links.test.ts +3 -1
  51. package/test/section-editor.test.ts +31 -8
  52. package/translations/en.json +29 -15
  53. package/translations/ja.json +25 -11
  54. package/translations/ko.json +29 -15
  55. package/translations/ms.json +25 -11
  56. package/translations/zh.json +25 -11
@@ -305,7 +305,7 @@ export class FigureEditor extends LitElement {
305
305
  [off] {
306
306
  display: none !important;
307
307
  }
308
- /* Which part the panel speaks of, and the way back to the whole figure. */
308
+ /* Which part the panel speaks of. */
309
309
  div[subject] {
310
310
  position: sticky;
311
311
  top: 0;
@@ -316,41 +316,6 @@ export class FigureEditor extends LitElement {
316
316
  background: var(--md-sys-color-surface-container-lowest);
317
317
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
318
318
  }
319
- nav[path] {
320
- display: flex;
321
- align-items: center;
322
- gap: 6px;
323
- min-width: 0;
324
- }
325
- nav[path] button[model-crumb] {
326
- flex: none;
327
- max-width: 55%;
328
- height: 28px;
329
- padding: 0 8px;
330
- overflow: hidden;
331
- white-space: nowrap;
332
- text-overflow: ellipsis;
333
- border: 1px solid var(--md-sys-color-outline-variant);
334
- border-radius: 999em;
335
- background: none;
336
- color: var(--md-sys-color-primary);
337
- cursor: pointer;
338
- }
339
- nav[path] button[model-crumb][on] {
340
- border-color: var(--md-sys-color-primary);
341
- background: var(--md-sys-color-primary-container);
342
- }
343
- nav[path] button[model-crumb]:focus-visible {
344
- outline: 2px solid var(--md-sys-color-primary);
345
- outline-offset: 1px;
346
- }
347
- nav[path] span[current] {
348
- min-width: 0;
349
- overflow: hidden;
350
- white-space: nowrap;
351
- text-overflow: ellipsis;
352
- font-weight: 600;
353
- }
354
319
  div[subject-summary] {
355
320
  display: grid;
356
321
  gap: 1px;
@@ -651,7 +616,26 @@ export class FigureEditor extends LitElement {
651
616
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
652
617
  * own Esc, the part removed, another figure opened -- it closes and lets go of the preview it held.
653
618
  */
654
- if (this.sectionEditing && this.sectionEditing !== this.selected) this.closeSectionEditor()
619
+ if (this.sectionEditing && this.sectionEditing !== this.selected) this.leaveSectionEditor(this.selected)
620
+ }
621
+ /**
622
+ * The selection moved off the part being edited. With nothing changed there the editor closes; with changes, the part
623
+ * stays selected while the editor asks (apply, let go, keep editing), and the move goes on only once it closed. A part
624
+ * gone or another figure opened has nothing to go back to: the editor closes.
625
+ */
626
+ private leaveSectionEditor(next: string) {
627
+ const editing = this.sectionEditing!
628
+ const editor = this.renderRoot.querySelector('figure-section-editor') as (HTMLElement & { dirty: boolean; requestClose(): Promise<boolean> }) | null
629
+ const view = this.session?.read()
630
+ const stillThere = !!view?.geometry.some(g => ownerOf(g.id) === editing)
631
+ if (!editor?.dirty || !stillThere) {
632
+ this.closeSectionEditor()
633
+ return
634
+ }
635
+ this.selected = editing
636
+ editor.requestClose().then(closed => {
637
+ if (closed && this.selected === editing) this.selected = next
638
+ })
655
639
  }
656
640
  /** The scene toggles: the mode already shown takes the gizmo away. */
657
641
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
@@ -1003,14 +987,11 @@ export class FigureEditor extends LitElement {
1003
987
  }
1004
988
  private renderPlaneAlign(asset: FigureAsset, unavailable: boolean) {
1005
989
  const placement = asset.placement ?? 'floor'
1006
- const icon = { floor: 'vertical_align_bottom', ceiling: 'vertical_align_top', wall: 'flip_to_back', center: 'vertical_align_center' }[placement]
1007
990
  /* 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). */
1008
991
  const contact = this.planeContactOf(this.session!.source(), this.selected)
1009
992
  const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
1010
- return html`<button plane-align=${placement} ?disabled=${unavailable} aria-label=${i18next.t(`figure.button.align-to-${placement}`)}
1011
- title=${i18next.t(`figure.text.align-to-${placement}${told}`)} @click=${() => this.alignToPlane()}>
1012
- <md-icon>${icon}</md-icon>
1013
- </button>`
993
+ return html`<button plane-align=${placement} ?disabled=${unavailable} title=${i18next.t(`figure.text.align-to-${placement}${told}`)}
994
+ @click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
1014
995
  }
1015
996
  private alignToPlane() {
1016
997
  const asset = parseFigureAsset(this.session!.source())
@@ -1031,27 +1012,33 @@ export class FigureEditor extends LitElement {
1031
1012
  } catch {
1032
1013
  return null
1033
1014
  }
1034
- const tool = (name: AlignTool, icon: string, label: string, title: string) =>
1035
- html`<button ?disabled=${unavailable} aria-label=${i18next.t(label)} title=${i18next.t(title)} @click=${() => this.align(name)}>
1036
- <md-icon>${icon}</md-icon>
1037
- </button>`
1038
- return html`<div tools title=${i18next.t('figure.text.tools-explained')}>
1039
- ${tool('centre-x', 'horizontal_distribute', 'figure.button.center-x', 'figure.text.center-on-x')}
1040
- ${tool('centre-z', 'vertical_align_center', 'figure.button.center-z', 'figure.text.center-on-z')}
1041
- ${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
1042
- ${(['x', 'z'] as const).map(axis => {
1043
- const able = canMirror(asset as never, this.selected, axis, reach)
1044
- return html`<button
1045
- ?disabled=${unavailable || !able}
1046
- aria-label=${i18next.t('figure.button.mirror-' + axis)}
1047
- title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
1048
- @click=${() => this.mirror(axis)}
1049
- >
1050
- <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
1051
- </button>`
1052
- })}
1053
- ${tool('snap', 'grid_on', 'figure.button.snap', 'figure.text.snap-explained')}
1054
- </div>`
1015
+ /*
1016
+ * In words, two rows by what they do (user 2026-10-01: 「무엇을 하는 것인지 모르겠다」): moving the part to the figure's
1017
+ * centre lines, onto its plane or onto the grid; and making a mirrored copy, which makes a part and moves nothing.
1018
+ */
1019
+ const tool = (name: AlignTool, label: string, title: string) =>
1020
+ html`<button ?disabled=${unavailable} title=${i18next.t(title)} @click=${() => this.align(name)}>${i18next.t(label)}</button>`
1021
+ const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(asset as never, this.selected, axis, reach) }))
1022
+ return html`<div vector-row>
1023
+ <label>${i18next.t('figure.label.tools')}</label>
1024
+ <div tools>
1025
+ ${tool('centre-x', 'figure.button.center-x', 'figure.text.center-on-x')}
1026
+ ${tool('centre-z', 'figure.button.center-z', 'figure.text.center-on-z')}
1027
+ ${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
1028
+ ${tool('snap', 'figure.button.snap', 'figure.text.snap-explained')}
1029
+ </div>
1030
+ </div>
1031
+ <div vector-row>
1032
+ <label>${i18next.t('figure.label.mirror-copy')}</label>
1033
+ <div tools>
1034
+ ${mirrors.map(
1035
+ ({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t('figure.text.mirror-explained')} @click=${() => this.mirror(axis)}>
1036
+ ${i18next.t('figure.button.mirror-' + axis)}
1037
+ </button>`
1038
+ )}
1039
+ </div>
1040
+ </div>
1041
+ ${mirrors.every(m => !m.able) ? html`<p hint>${i18next.t('figure.text.mirror-would-overlap')}</p>` : nothing}`
1055
1042
  }
1056
1043
  /**
1057
1044
  * A part's name. It renames every value the part owns, and what refers to them follows
@@ -1872,36 +1859,27 @@ export class FigureEditor extends LitElement {
1872
1859
  </div>`
1873
1860
  }
1874
1861
  /**
1875
- * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
1876
- * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
1877
- * model name is the same move as the parts list's root; there is no separate "whole figure" link.
1862
+ * The part the panel speaks of, held at its top: its kind, how its size answers, what holds it (designer 2026-09-27,
1863
+ * B). No path above it (user 2026-10-01): the parts list and the canvas already show which part is chosen, and the
1864
+ * list's root is the way back to the model's settings.
1878
1865
  */
1879
1866
  private renderSubject() {
1880
- const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name')
1881
1867
  let summary: { kind: string; size: string; relation: string } | undefined
1882
1868
  try {
1883
1869
  summary = this.selected ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined
1884
1870
  } catch {
1885
1871
  summary = undefined
1886
1872
  }
1873
+ if (!summary) return nothing
1887
1874
  return html`<div subject>
1888
- <nav path aria-label=${i18next.t('figure.label.panel-path')}>
1889
- <button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
1890
- aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
1891
- ${this.selected
1892
- ? html`<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
1893
- : html`<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
1894
- </nav>
1895
- ${summary
1896
- ? html`<div subject-summary>
1897
- ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1898
- <small>${summary.size}</small><small>${summary.relation}</small>
1899
- </div>`
1900
- : nothing}
1875
+ <div subject-summary>
1876
+ ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1877
+ <small>${summary.size}</small><small>${summary.relation}</small>
1878
+ </div>
1901
1879
  </div>`
1902
1880
  }
1903
1881
  /**
1904
- * The model's settings: the same move from the parts list's root and from the panel's path (B.1). It shows them on
1882
+ * The model's settings, from the parts list's root (B.1). It shows them on
1905
1883
  * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
1906
1884
  */
1907
1885
  private showModelSettings() {
@@ -2144,7 +2122,7 @@ export class FigureEditor extends LitElement {
2144
2122
  ? html`<section>
2145
2123
  <h3>${i18next.t(vectorTitle)}</h3>
2146
2124
  ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
2147
- ${tools ? html`<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
2125
+ ${tools ?? nothing}
2148
2126
  </section>`
2149
2127
  : nothing}
2150
2128
  ${groups.singles.length
@@ -2255,14 +2233,7 @@ export class FigureEditor extends LitElement {
2255
2233
  if (agreed) this.linkCommand(refit.step)
2256
2234
  }
2257
2235
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
2258
- /* A drawn section's point is called by its number and axis, as the section editor calls it (ADR-0100 §2.1, Q9). */
2259
- const point = /^point\.p(\d+)\.([xyz])$/.exec(name)
2260
- const apex = /^apex\.([xyz])$/.exec(name)
2261
- const words = point
2262
- ? [i18next.t('figure.label.section-editor-coordinate', { point: point[1]!, axis: point[2]!.toUpperCase() })]
2263
- : apex
2264
- ? [`${i18next.t('figure.label.section-editor-apex-mark')} ${apex[1]!.toUpperCase()}`]
2265
- : name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2236
+ const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2266
2237
  return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
2267
2238
  ><input
2268
2239
  aria-label=${field.id}
@@ -0,0 +1,152 @@
1
+ import { figurePartFaceOf } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureFace } from '@hatiolab/figure-model'
3
+
4
+ import { boxOf } from './align.js'
5
+ import { alignAxesOf, facesOf, faceAxisOf, hasInstanceSize, mountingPlaneOf, type Alignment } from './links.js'
6
+
7
+ /*
8
+ * What the 붙이기 drawing reads (user 2026-10-01): the face a part is fastened to, seen from above, and where the part
9
+ * lies on it. A fastening is chosen by the target's face and how (올려놓기 · 같은 면에 맞추기 · 가운데를 면에); the part's
10
+ * own face follows from those -- the kernel takes no other pairing (figure-model `attach`: meet is the facing face,
11
+ * flush the same one, a face along another axis would need a turn).
12
+ */
13
+
14
+ type Axis = 'x' | 'y' | 'z'
15
+ const AXES: Axis[] = ['x', 'y', 'z']
16
+ export type FastenMode = 'meet' | 'flush' | 'centre'
17
+ export type FastenTarget = { part: string; face: string } | 'plane'
18
+
19
+ const OPPOSITE: Record<FigureFace, FigureFace> = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' }
20
+ 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] }
21
+
22
+ /** The world direction a part's face looks along now, turns and all; by its name when the face's outline is not read. */
23
+ function directionOf(asset: FigureAsset, part: string, face: string): [Axis, 1 | -1] | undefined {
24
+ const area = (() => {
25
+ try {
26
+ return figurePartFaceOf(asset, part, face)
27
+ } catch {
28
+ return undefined
29
+ }
30
+ })()
31
+ if (area) {
32
+ const k = area.normal.findIndex(v => Math.abs(v) > 0.5)
33
+ if (k >= 0) return [AXES[k]!, area.normal[k]! > 0 ? 1 : -1]
34
+ }
35
+ return face in NORMAL ? NORMAL[face as FigureFace] : undefined
36
+ }
37
+
38
+ /**
39
+ * The part's own face a fastening takes: the one looking against the target's face (올려놓기), with it (같은 면), or its
40
+ * centre. Read from where each face looks now, so a part standing turned offers the face that is really there.
41
+ * Undefined when the part has no such face.
42
+ */
43
+ export function myFaceFor(asset: FigureAsset, part: string, target: FastenTarget, mode: FastenMode): FigureFace | 'centre' | undefined {
44
+ if (mode === 'centre') return target === 'plane' ? undefined : 'centre'
45
+ const want: [Axis, 1 | -1] | undefined =
46
+ target === 'plane'
47
+ ? /* The plane is met from above on a floor, from in front on a wall. */ [mountingPlaneOf(asset).normal, -1]
48
+ : (() => {
49
+ const d = directionOf(asset, target.part, target.face)
50
+ return d && [d[0], (mode === 'meet' ? -d[1] : d[1]) as 1 | -1]
51
+ })()
52
+ if (!want) return undefined
53
+ const mine = facesOf(asset, part).filter((f): f is FigureFace => f in NORMAL)
54
+ const hit = mine.find(f => {
55
+ const d = directionOf(asset, part, f)
56
+ return d && d[0] === want[0] && d[1] === want[1]
57
+ })
58
+ return hit ?? (target !== 'plane' && mode === 'meet' && target.face in OPPOSITE ? OPPOSITE[target.face as FigureFace] : undefined)
59
+ }
60
+
61
+ /** Which way the fastening is, read back from the part's face and facing. */
62
+ export const modeOf = (face: string, facing: 'meet' | 'flush'): FastenMode => (face === 'centre' ? 'centre' : facing)
63
+
64
+ /**
65
+ * The face seen from above: its two axes across (as the form's alignment names them), the face's size on each -- none
66
+ * for a plane with no declared size -- the part's footprint on each, and whether its edges can be lined up with.
67
+ */
68
+ export interface FaceView {
69
+ axes: [Axis, Axis]
70
+ face: Partial<Record<Axis, number>>
71
+ part: Record<Axis, number>
72
+ edges: Record<Axis, boolean>
73
+ }
74
+
75
+ export function faceViewOf(asset: FigureAsset, part: string, target: FastenTarget): FaceView | undefined {
76
+ const across = alignAxesOf(asset, part, target)
77
+ if (across.length !== 2) return undefined
78
+ const box = boxOf(asset, part)
79
+ if (!box) return undefined
80
+ const axes = [across[0]!.axis, across[1]!.axis] as [Axis, Axis]
81
+ const face: Partial<Record<Axis, number>> = {}
82
+ if (target === 'plane') {
83
+ for (const a of axes) if (hasInstanceSize(asset, a)) face[a] = asset.designInputs[`size.${a}`]!
84
+ } else {
85
+ const theirs = boxOf(asset, target.part)
86
+ const normal = faceAxisOf(asset, target.part, target.face)
87
+ for (const a of axes) if (theirs && a !== normal) face[a] = theirs.extent[a]
88
+ }
89
+ return {
90
+ axes,
91
+ face,
92
+ part: { x: box.extent.x, y: box.extent.y, z: box.extent.z },
93
+ edges: Object.fromEntries(across.map(o => [o.axis, o.edges])) as Record<Axis, boolean>
94
+ }
95
+ }
96
+
97
+ /** Where the part's centre is from the face's centre along one axis, for the way it is aligned there. */
98
+ export function offsetOf(how: Alignment | undefined, mm: number | undefined, face: number | undefined, part: number): number {
99
+ if (how === 'min' && face !== undefined) return -(face - part) / 2
100
+ if (how === 'max' && face !== undefined) return (face - part) / 2
101
+ if (how === 'mm' || how === 'share') return mm ?? 0
102
+ return 0
103
+ }
104
+
105
+ /**
106
+ * A place dropped on the face, as the form's alignment: caught at the centre, at an edge from inside (level with it:
107
+ * `min` / `max`, where the edges can be lined up with), or at an edge from outside (touching it, as a distance), when
108
+ * within `catchMm`; anywhere else a distance off centre, whole millimetres.
109
+ */
110
+ export function alignmentAt(d: number, face: number | undefined, part: number, edges: boolean, catchMm: number): { how: Alignment; mm?: number; caught?: 'centre' | 'inside' | 'outside' } {
111
+ if (Math.abs(d) <= catchMm) return { how: 'centre', caught: 'centre' }
112
+ if (face !== undefined) {
113
+ const inside = (face - part) / 2,
114
+ outside = (face + part) / 2
115
+ for (const [at, how] of [
116
+ [-inside, 'min'],
117
+ [inside, 'max']
118
+ ] as const)
119
+ if (Math.abs(d - at) <= catchMm && inside > 0) return edges ? { how, caught: 'inside' } : { how: 'mm', mm: Math.round(at), caught: 'inside' }
120
+ for (const at of [-outside, outside]) if (Math.abs(d - at) <= catchMm) return { how: 'mm', mm: Math.round(at), caught: 'outside' }
121
+ }
122
+ return { how: 'mm', mm: Math.round(d) }
123
+ }
124
+
125
+ /** How far the footprint comes past the face's edges, per side, and whether it touches the face at all. */
126
+ export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching: boolean; sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> } {
127
+ const sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> = []
128
+ let touching = true
129
+ for (const a of view.axes) {
130
+ const face = view.face[a]
131
+ if (face === undefined) continue
132
+ const lo = at[a] - view.part[a] / 2,
133
+ hi = at[a] + view.part[a] / 2
134
+ if (lo >= face / 2 || hi <= -face / 2) touching = false
135
+ if (-face / 2 - lo > 0.5) sides.push({ axis: a, side: 'low', mm: Math.round(-face / 2 - lo) })
136
+ if (hi - face / 2 > 0.5) sides.push({ axis: a, side: 'high', mm: Math.round(hi - face / 2) })
137
+ }
138
+ return { touching, sides }
139
+ }
140
+
141
+ /** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
142
+ export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || j.body1.face === 'centre'))
143
+
144
+ /**
145
+ * 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
146
+ * for Y, which rises, so a front face is seen as it stands and not upside down.
147
+ */
148
+ export function screenOf(axes: readonly [Axis, Axis]): { h: Axis; v: Axis; sign: Record<Axis, 1 | -1> } {
149
+ const h = axes.includes('x') ? 'x' : 'z'
150
+ const v = axes.find(a => a !== h)!
151
+ return { h, v, sign: { x: 1, y: 1, z: 1, [v]: v === 'y' ? -1 : 1 } as Record<Axis, 1 | -1> }
152
+ }
@@ -46,7 +46,14 @@ export function groupFields(fields: readonly FieldLike[], part?: string): { vect
46
46
  AXES.forEach((axis, i) => byId.has(ids[i]!) && (cells[axis] = byId.get(ids[i]!)!))
47
47
  vectors.push({ kind: 'vector', name, cells })
48
48
  }
49
- const singles: FieldGroup[] = own.filter(f => !used.has(f.id)).map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
49
+ /*
50
+ A drawn section's points and a pyramid's apex are edited in the section editor, where a move is checked as it is made
51
+ and the points stand in the outline's order (user 2026-10-01): not here, one field each, in the order they were made.
52
+ */
53
+ const drawn = (id: string) => /^(point\.[^.]+|apex)\.[xyz]$/.test(id.slice(prefix.length))
54
+ const singles: FieldGroup[] = own
55
+ .filter(f => !used.has(f.id) && !drawn(f.id))
56
+ .map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
50
57
  return { vectors, singles }
51
58
  }
52
59
 
@@ -0,0 +1,259 @@
1
+ import { css, html, LitElement, nothing, svg } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+
4
+ import { i18next, localize } from '@operato/i18n'
5
+ import { figureCavitySectionOf } from '@hatiolab/figure-model'
6
+ import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape, FigureSection } from '@hatiolab/figure-model'
7
+ import { cavityRefusalOf, cavityWorkingOf, END_FACE, endToggled, mm, SIDE_FACE, wallDragged } from './section-controls.js'
8
+
9
+ type Axis = 'x' | 'y' | 'z'
10
+ type End = 'start' | 'end'
11
+ type Body = { id: string; shape: FigureEvaluatedShape }
12
+
13
+ /** How the section is laid on the screen, by the axis it is pushed along: across to the right, then down. */
14
+ const SCREEN: Record<Axis, [Axis, Axis]> = { y: ['x', 'z'], x: ['z', 'y'], z: ['x', 'y'] }
15
+
16
+ /**
17
+ * 속 공간 그림 — the inside of a pushed part drawn, and edited by drawing (user 2026-10-01: 「이해와 편집이 직관적이지 않다」).
18
+ *
19
+ * Seen along the push, the section and its inside: a handle in the middle of each wall is dragged to its thickness,
20
+ * every wall together unless 면마다 벽 is on. Cut along the push, the walls and the two ends: an end pressed opens or
21
+ * closes it, a closed end's inner line is dragged to its thickness. While dragging, the figure behind shows what the
22
+ * inside makes (`command-live`); letting go sends one `set-cavity`. What the kernel would refuse is drawn in the error
23
+ * colour and said, by the kernel's own check, and is not sent.
24
+ */
25
+ @customElement('figure-cavity-drawing')
26
+ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
27
+ static styles = css`
28
+ :host {
29
+ display: grid;
30
+ gap: 6px;
31
+ }
32
+ svg {
33
+ width: 100%;
34
+ display: block;
35
+ touch-action: none;
36
+ user-select: none;
37
+ }
38
+ svg text {
39
+ font: inherit;
40
+ }
41
+ small[refusal] {
42
+ color: var(--md-sys-color-error);
43
+ }
44
+ `
45
+
46
+ @property({ attribute: false }) body?: Body
47
+ @property({ type: Boolean }) locked = false
48
+ /** Walls one per side: a handle moves its own wall only. */
49
+ @property({ type: Boolean }) perSide = false
50
+
51
+ /** What is being dragged, and the inside it makes; undefined when nothing is. */
52
+ @state() private working?: ReturnType<typeof cavityWorkingOf>
53
+ @state() private refusal = ''
54
+ private dragging: { kind: 'wall'; side: string } | { kind: 'end'; end: End } | null = null
55
+ private pressedAt = 0
56
+
57
+ private get now() {
58
+ return this.working ?? (this.body ? cavityWorkingOf(this.body.shape) : undefined)
59
+ }
60
+
61
+ private request(w: NonNullable<ReturnType<typeof cavityWorkingOf>>): FigureCavityRequest {
62
+ return { wall: { ...w.wall }, start: w.start, end: w.end }
63
+ }
64
+
65
+ private action(w: NonNullable<ReturnType<typeof cavityWorkingOf>>): FigureAuthoringAction {
66
+ return { kind: 'set-cavity', part: this.body!.id, cavity: this.request(w) }
67
+ }
68
+
69
+ private judge(w: NonNullable<ReturnType<typeof cavityWorkingOf>>): string {
70
+ const ask = cavityRefusalOf(this.body!.shape, this.request(w))
71
+ if (!ask) return ''
72
+ if (ask.code === 'wall-too-thick') return i18next.t('figure.text.section-wall-over', { max: String(ask.max) })
73
+ if (ask.code === 'ends-too-thick') return i18next.t('figure.text.section-ends-over', { max: String(ask.max) })
74
+ return i18next.t('figure.text.section-not-positive')
75
+ }
76
+
77
+ /** The working inside while dragging: drawn, judged, and previewed behind when the kernel would take it. */
78
+ private hold(w: NonNullable<ReturnType<typeof cavityWorkingOf>>) {
79
+ this.working = w
80
+ this.refusal = this.judge(w)
81
+ this.emit('command-live', this.refusal ? null : this.action(w))
82
+ }
83
+
84
+ private release() {
85
+ const w = this.working
86
+ this.dragging = null
87
+ this.working = undefined
88
+ if (!w) return
89
+ /* Refused: nothing is sent, and the reason stays said until the next drag -- letting go must not look like taking it. */
90
+ if (this.refusal) {
91
+ this.emit('command-live', null)
92
+ return
93
+ }
94
+ this.emit('command', this.action(w))
95
+ }
96
+
97
+ private emit(name: 'command' | 'command-live', action: FigureAuthoringAction | null) {
98
+ this.dispatchEvent(new CustomEvent(name, { detail: { action }, bubbles: true, composed: true }))
99
+ }
100
+
101
+ private point(ev: PointerEvent, el: SVGSVGElement) {
102
+ const pt = el.createSVGPoint()
103
+ pt.x = ev.clientX
104
+ pt.y = ev.clientY
105
+ return pt.matrixTransform(el.getScreenCTM()!.inverse())
106
+ }
107
+
108
+ render() {
109
+ const e = this.body?.shape.extrusion
110
+ const w = this.now
111
+ if (!e || !w || (e.section.kind !== 'rectangle' && e.section.kind !== 'ellipse')) return nothing
112
+ return html`${this.top(e, w)}${this.side(e, w)}
113
+ ${this.refusal ? html`<small refusal role="alert">${this.refusal}</small>` : nothing}`
114
+ }
115
+
116
+ /** Seen along the push: the section, its inside and a handle on each wall. */
117
+ private top(e: NonNullable<FigureEvaluatedShape['extrusion']>, w: NonNullable<ReturnType<typeof cavityWorkingOf>>) {
118
+ const [h, v] = SCREEN[e.axis]
119
+ const s = e.section
120
+ const across = (a: Axis) => (s.kind === 'rectangle' ? s.size[a]! : 2 * (s as { radius: Record<string, number> }).radius[a]!)
121
+ const W = 300,
122
+ H = 170,
123
+ pad = 26
124
+ const k = Math.min((W - 2 * pad) / across(h), (H - 2 * pad) / across(v))
125
+ const ox = W / 2,
126
+ oy = H / 2
127
+ const inside = figureCavitySectionOf(s as FigureSection, e.axis, w.wall)
128
+ const bad = !!this.refusal || !('section' in inside)
129
+ const shape = (sec: FigureSection, c: Partial<Record<Axis, number>>, attrs: string) => {
130
+ const cx = ox + (c[h] ?? 0) * k,
131
+ cy = oy + (c[v] ?? 0) * k
132
+ if (sec.kind === 'ellipse') return svg`<ellipse cx=${cx} cy=${cy} rx=${sec.radius[h]! * k} ry=${sec.radius[v]! * k} style=${attrs} />`
133
+ const r = sec as { size: Record<string, number>; corner?: Record<string, number> }
134
+ return svg`<rect x=${cx - (r.size[h]! * k) / 2} y=${cy - (r.size[v]! * k) / 2} width=${r.size[h]! * k} height=${r.size[v]! * k}
135
+ rx=${(r.corner?.[h] ?? 0) * k} ry=${(r.corner?.[v] ?? 0) * k} style=${attrs} />`
136
+ }
137
+ const half = { [h]: (across(h) * k) / 2, [v]: (across(v) * k) / 2 } as Record<Axis, number>
138
+ /* Each wall's handle sits on the inside's edge, half way along it; the value is the wall's own. */
139
+ const handles = [`${h}-`, `${h}+`, `${v}-`, `${v}+`].map(side => {
140
+ const a = side[0] as Axis,
141
+ low = side[1] === '-'
142
+ const edge = (low ? -1 : 1) * (half[a] - w.wall[side]! * k)
143
+ const x = a === h ? ox + edge : ox,
144
+ y = a === v ? oy + edge : oy
145
+ /* The number stands in the wall itself, half way through it; a wall too thin for it has it just outside. */
146
+ const band = w.wall[side]! * k
147
+ const mid = (low ? -1 : 1) * (half[a] - (band < 16 ? -9 : band / 2))
148
+ const tx = a === h ? ox + mid : x + 14,
149
+ ty = a === v ? oy + mid + 4 : y - 10
150
+ return svg`<g wall=${side} style=${`cursor:${a === h ? 'ew-resize' : 'ns-resize'}`}
151
+ @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'wall', side })}>
152
+ <rect x=${x - 6} y=${y - 6} width="12" height="12" rx="2" style="fill:var(--md-sys-color-surface);stroke:var(--md-sys-color-primary);stroke-width:1.5" />
153
+ <text x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? 'middle' : 'start'} style="fill:var(--md-sys-color-on-surface)">${mm(w.wall[side]!)}</text>
154
+ <title>${i18next.t('figure.label.face-' + SIDE_FACE[side])} ${i18next.t('figure.label.section-wall')}</title>
155
+ </g>`
156
+ })
157
+ return svg`<svg viewBox=${`0 0 ${W} ${H}`} role="img" aria-label=${i18next.t('figure.label.section-cavity-top')}
158
+ @pointermove=${(ev: PointerEvent) => this.drag(ev, e, k, ox, oy, half)}
159
+ @pointerup=${() => this.release()} @pointercancel=${() => this.release()}>
160
+ ${shape(s as FigureSection, {}, 'fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:1.5')}
161
+ ${'section' in inside
162
+ ? shape(inside.section, inside.centre, `fill:var(--md-sys-color-surface);stroke:${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'};stroke-width:1.2;stroke-dasharray:4 3`)
163
+ : nothing}
164
+ ${this.locked ? nothing : handles}
165
+ </svg>`
166
+ }
167
+
168
+ /** Cut along the push: the walls across the first plane axis, and the two ends -- pressed to open or close. */
169
+ private side(e: NonNullable<FigureEvaluatedShape['extrusion']>, w: NonNullable<ReturnType<typeof cavityWorkingOf>>) {
170
+ const [h] = SCREEN[e.axis]
171
+ const s = e.section
172
+ const width = s.kind === 'rectangle' ? s.size[h]! : 2 * (s as { radius: Record<string, number> }).radius[h]!
173
+ const length = e.end - e.start
174
+ const W = 300,
175
+ H = 170,
176
+ padX = 40,
177
+ padY = 26
178
+ const k = Math.min((W - 2 * padX) / width, (H - 2 * padY) / length)
179
+ const x0 = W / 2 - (width * k) / 2,
180
+ x1 = W / 2 + (width * k) / 2,
181
+ yTop = H / 2 - (length * k) / 2,
182
+ yBot = H / 2 + (length * k) / 2
183
+ const wl = w.wall[`${h}-`]! * k,
184
+ wr = w.wall[`${h}+`]! * k
185
+ const thick = (end: End) => (w[end] === 'open' ? 0 : (w[end] as { closed: number }).closed * k)
186
+ /* The push's start is drawn at the bottom: a tote stands on its floor. */
187
+ const floor = thick('start'),
188
+ lid = thick('end')
189
+ const fill = 'fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:1.2'
190
+ const endZone = (end: End, y: number, t: number) => {
191
+ const open = w[end] === 'open'
192
+ const words = open
193
+ ? i18next.t('figure.label.section-end-open')
194
+ : `${i18next.t('figure.label.section-end-closed')} ${mm((w[end] as { closed: number }).closed)}`
195
+ const label = `${i18next.t('figure.label.face-' + END_FACE(e.axis, end))}: ${words}`
196
+ const inner = end === 'start' ? y - t : y + t
197
+ return svg`<g end=${end} style=${`cursor:${open ? 'pointer' : 'ns-resize'}`}
198
+ @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'end', end })}>
199
+ <rect x=${x0 + wl} y=${Math.min(y, inner) - 8} width=${x1 - x0 - wl - wr} height=${Math.abs(t) + 16} style="fill:transparent" />
200
+ ${open ? svg`<line x1=${x0 + wl} y1=${y} x2=${x1 - wr} y2=${y} style="stroke:var(--md-sys-color-outline);stroke-dasharray:3 3" />` : nothing}
201
+ <text x=${W / 2} y=${end === 'start' ? yBot + 16 : yTop - 8} font-size="11" text-anchor="middle" style="fill:var(--md-sys-color-primary)">${label}</text>
202
+ <title>${label}</title>
203
+ </g>`
204
+ }
205
+ return svg`<svg viewBox=${`0 0 ${W} ${H + 8}`} role="img" aria-label=${i18next.t('figure.label.section-cavity-side')}
206
+ @pointermove=${(ev: PointerEvent) => this.dragEnd(ev, k, yTop, yBot)}
207
+ @pointerup=${(ev: PointerEvent) => this.releaseEnd(ev)} @pointercancel=${() => this.release()}>
208
+ <rect x=${x0} y=${yTop} width=${wl} height=${yBot - yTop} style=${fill} />
209
+ <rect x=${x1 - wr} y=${yTop} width=${wr} height=${yBot - yTop} style=${fill} />
210
+ ${floor ? svg`<rect x=${x0 + wl} y=${yBot - floor} width=${x1 - x0 - wl - wr} height=${floor} style=${fill} />` : nothing}
211
+ ${lid ? svg`<rect x=${x0 + wl} y=${yTop} width=${x1 - x0 - wl - wr} height=${lid} style=${fill} />` : nothing}
212
+ ${this.locked ? nothing : [endZone('start', yBot, floor), endZone('end', yTop, lid)]}
213
+ </svg>`
214
+ }
215
+
216
+ private grab(ev: PointerEvent, what: NonNullable<FigureCavityDrawing['dragging']>) {
217
+ if (this.locked) return
218
+ ev.stopPropagation()
219
+ ;(ev.currentTarget as Element).closest('svg')!.setPointerCapture(ev.pointerId)
220
+ this.dragging = what
221
+ this.pressedAt = ev.clientY
222
+ this.refusal = ''
223
+ }
224
+
225
+ private drag(ev: PointerEvent, e: NonNullable<FigureEvaluatedShape['extrusion']>, k: number, ox: number, oy: number, half: Record<Axis, number>) {
226
+ const d = this.dragging
227
+ if (d?.kind !== 'wall') return
228
+ const [h] = SCREEN[e.axis]
229
+ const p = this.point(ev, ev.currentTarget as SVGSVGElement)
230
+ const a = d.side[0] as Axis,
231
+ low = d.side[1] === '-'
232
+ const at = a === h ? p.x - ox : p.y - oy
233
+ const value = (half[a] - (low ? -at : at)) / k
234
+ this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) })
235
+ }
236
+
237
+ private dragEnd(ev: PointerEvent, k: number, yTop: number, yBot: number) {
238
+ const d = this.dragging
239
+ if (d?.kind !== 'end' || this.now![d.end] === 'open') return
240
+ if (Math.abs(ev.clientY - this.pressedAt) < 3 && !this.working) return
241
+ const p = this.point(ev, ev.currentTarget as SVGSVGElement)
242
+ const t = d.end === 'start' ? (yBot - p.y) / k : (p.y - yTop) / k
243
+ this.hold({ ...this.now!, [d.end]: { closed: Math.max(1, Math.round(t)) } })
244
+ }
245
+
246
+ /** A press on an end that did not move is a toggle; a drag was a thickness. */
247
+ private releaseEnd(ev: PointerEvent) {
248
+ const d = this.dragging
249
+ if (d?.kind === 'end' && !this.working && Math.abs(ev.clientY - this.pressedAt) < 3) {
250
+ this.dragging = null
251
+ const next = { ...this.now!, [d.end]: endToggled(this.body!.shape, this.now![d.end]) }
252
+ this.refusal = this.judge(next)
253
+ if (!this.refusal) this.emit('command', this.action(next))
254
+ return
255
+ }
256
+ this.release()
257
+ }
258
+ }
259
+