@things-factory/figure-ui 10.1.88 → 10.1.90

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 (41) hide show
  1. package/client/modeller/editor.ts +29 -3
  2. package/client/modeller/figure-parts.ts +2 -0
  3. package/client/modeller/figure-section-editor.ts +93 -8
  4. package/client/modeller/figure-section-panel.ts +3 -1
  5. package/client/modeller/maturity.ts +8 -1
  6. package/client/modeller/palette.ts +13 -0
  7. package/client/modeller/section-drawing.ts +25 -5
  8. package/client/modeller/starting-shape.ts +5 -3
  9. package/dist-client/modeller/clips.d.ts +1 -1
  10. package/dist-client/modeller/editor.d.ts +6 -0
  11. package/dist-client/modeller/editor.js +27 -3
  12. package/dist-client/modeller/editor.js.map +1 -1
  13. package/dist-client/modeller/figure-parts.js +2 -0
  14. package/dist-client/modeller/figure-parts.js.map +1 -1
  15. package/dist-client/modeller/figure-section-editor.d.ts +8 -0
  16. package/dist-client/modeller/figure-section-editor.js +89 -8
  17. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  18. package/dist-client/modeller/figure-section-panel.js +3 -1
  19. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  20. package/dist-client/modeller/maturity.js +8 -1
  21. package/dist-client/modeller/maturity.js.map +1 -1
  22. package/dist-client/modeller/palette.js +13 -0
  23. package/dist-client/modeller/palette.js.map +1 -1
  24. package/dist-client/modeller/section-drawing.d.ts +12 -0
  25. package/dist-client/modeller/section-drawing.js +22 -5
  26. package/dist-client/modeller/section-drawing.js.map +1 -1
  27. package/dist-client/modeller/starting-shape.d.ts +3 -2
  28. package/dist-client/modeller/starting-shape.js +4 -0
  29. package/dist-client/modeller/starting-shape.js.map +1 -1
  30. package/dist-client/route.d.ts +1 -1
  31. package/dist-client/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +5 -5
  33. package/test/browser/acceptance-page.js +9 -0
  34. package/test/palette-icons.test.ts +5 -2
  35. package/test/parts-wiring.test.ts +1 -1
  36. package/test/section-editor.test.ts +57 -3
  37. package/translations/en.json +7 -1
  38. package/translations/ja.json +7 -1
  39. package/translations/ko.json +7 -1
  40. package/translations/ms.json +7 -1
  41. package/translations/zh.json +7 -1
@@ -35,6 +35,8 @@ import { ScrollbarStyles } from '@operato/styles'
35
35
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './align.js'
36
36
  import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
37
37
  import { paletteRecipe } from './palette.js'
38
+ import { apexOver, drawnOf } from './section-drawing.js'
39
+ import { END_FACE } from './section-controls.js'
38
40
  import type { FigureCanvas } from './canvas.js'
39
41
  import { nudgeOf, placementOf, type ScaleAnchor } from './stage-model.js'
40
42
 
@@ -1785,20 +1787,41 @@ export class FigureEditor extends LitElement {
1785
1787
  private renderSectionEditor(geometry: readonly { id: string }[]) {
1786
1788
  const part = geometry.find(g => ownerOf(g.id) === this.selected) as { id: string; shape: FigureEvaluatedShape } | undefined
1787
1789
  /* A door frame's section is its own width, height, jamb and head: its fields edit it, not an outline. */
1788
- if (!part?.shape?.extrusion || part.shape.dimensions.jamb !== undefined) return nothing
1790
+ const drawn = part?.shape && drawnOf(part.shape)
1791
+ if (!part || !drawn || part.shape.dimensions.jamb !== undefined) return nothing
1789
1792
  const asset = this.asset() as FigureAsset
1790
1793
  const measures = figureSectionMeasuresOf(asset, this.selected)
1791
- const kept = figureScaleAnchorOf(asset, this.selected)?.[part.shape.extrusion.axis]
1794
+ const kept = figureScaleAnchorOf(asset, this.selected)?.[drawn.axis]
1792
1795
  return html`<figure-section-editor
1793
1796
  .body=${{ ...part, id: this.selected }}
1794
1797
  .measures=${measures}
1795
1798
  .kept=${kept}
1799
+ .pointBlocked=${this.pointBlockedOf(drawn)}
1796
1800
  .revision=${this.session?.read().revision ?? 0}
1797
1801
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1798
1802
  @command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
1799
1803
  @section-editor-close=${() => this.closeSectionEditor()}
1800
1804
  ></figure-section-editor>`
1801
1805
  }
1806
+ /**
1807
+ * Why a drawn push's end cannot be drawn to one point now: the kernel's own refusal of that command on a copy (what is
1808
+ * fastened to the end face), said as the face. Asked once per figure revision and part.
1809
+ */
1810
+ private pointBlocked?: { key: string; words: string }
1811
+ private pointBlockedOf(drawn: NonNullable<ReturnType<typeof drawnOf>>): string {
1812
+ if (drawn.apex || drawn.section.kind !== 'polygon' || !this.session) return ''
1813
+ const key = `${this.session.read().revision}|${this.selected}`
1814
+ if (this.pointBlocked?.key === key) return this.pointBlocked.words
1815
+ let words = ''
1816
+ try {
1817
+ this.session.previewCommand({ kind: 'set-section', part: this.selected!, apex: apexOver(drawn.section.points, drawn.axis) })
1818
+ } catch (error) {
1819
+ if ((error as { code?: string }).code === 'FEATURE_MAPPING_REQUIRED')
1820
+ words = i18next.t('figure.text.section-editor-apex-blocked', { face: i18next.t('figure.label.face-' + END_FACE(drawn.axis, 'end')) })
1821
+ }
1822
+ this.pointBlocked = { key, words }
1823
+ return words
1824
+ }
1802
1825
  private renderStart() {
1803
1826
  const shapes: FigurePaletteKind[] = ['box', 'cylinder', 'plate']
1804
1827
  /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
@@ -2234,9 +2257,12 @@ export class FigureEditor extends LitElement {
2234
2257
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
2235
2258
  /* A drawn section's point is called by its number and axis, as the section editor calls it (ADR-0100 §2.1, Q9). */
2236
2259
  const point = /^point\.p(\d+)\.([xyz])$/.exec(name)
2260
+ const apex = /^apex\.([xyz])$/.exec(name)
2237
2261
  const words = point
2238
2262
  ? [i18next.t('figure.label.section-editor-coordinate', { point: point[1]!, axis: point[2]!.toUpperCase() })]
2239
- : name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
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 }))
2240
2266
  return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
2241
2267
  ><input
2242
2268
  aria-label=${field.id}
@@ -46,6 +46,7 @@ const KIND_ICON: Record<string, string> = {
46
46
  'rounded-box': 'crop_square',
47
47
  'hollow-box': 'check_box_outline_blank',
48
48
  polygon: 'pentagon',
49
+ pyramid: 'details',
49
50
  portal: 'door_front'
50
51
  }
51
52
 
@@ -62,6 +63,7 @@ const RECIPES: Array<{ kind: FigurePaletteKind; icon: string; label: string }> =
62
63
  { kind: 'sphere', icon: 'circle', label: 'figure.label.primitive-sphere' },
63
64
  { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
64
65
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
66
+ { kind: 'pyramid', icon: 'details', label: 'figure.label.primitive-pyramid' },
65
67
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
66
68
  { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
67
69
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
@@ -6,7 +6,7 @@ import { i18next, localize } from '@operato/i18n'
6
6
  import { figureCavitySectionOf, figureSectionSidesOf } from '@hatiolab/figure-model'
7
7
  import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
8
8
  import { SidePanelStyles } from './side-panel-styles.js'
9
- import { insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
9
+ import { apexOver, drawnOf, insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
10
10
  import { END_FACE } from './section-controls.js'
11
11
 
12
12
  type Axis = 'x' | 'y' | 'z'
@@ -28,6 +28,10 @@ export const NARROW_BELOW = 900
28
28
  * part's placement or fastening keeps (§4.1: read here, changed elsewhere). A measure that follows another value is
29
29
  * shown and not offered.
30
30
  *
31
+ * A drawn section's end can be drawn to one point -- a pyramid, a tetrahedron from three points (§4.1.1, A5-1): the
32
+ * apex is dragged or typed like a point, and is not offered while something is fastened to the end it takes away (the
33
+ * kernel's own refusal, `pointBlocked`).
34
+ *
31
35
  * What the kernel would refuse is marked on the outline and said in the list; apply is held back, and the solid shows
32
36
  * the last valid state, which the caption names. Apply sends one `set-section` command (one step of the history); cancel
33
37
  * or Esc sends nothing and lets the preview go. The undo here is the editor's own. If the figure changes while the editor
@@ -150,6 +154,15 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
150
154
  min-width: 0;
151
155
  font: inherit;
152
156
  }
157
+ label[apex] {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 6px;
161
+ grid-column: 1 / -1;
162
+ }
163
+ label[apex] small {
164
+ flex-basis: 100%;
165
+ }
153
166
  ol[points] {
154
167
  list-style: none;
155
168
  margin: 0;
@@ -226,6 +239,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
226
239
  @property({ attribute: false }) measures?: { measures: Record<Axis, FigureSectionMeasure>; round: boolean }
227
240
  /** Along the push, the face a length change keeps: 1 its start, -1 its end, 0 its centre; undefined when it cannot be said. */
228
241
  @property({ attribute: false }) kept?: -1 | 0 | 1
242
+ /** Why a push's end cannot be drawn to a point now, in the kernel's words (what is fastened to it); empty when it can. */
243
+ @property({ attribute: false }) pointBlocked = ''
229
244
  /** The figure's revision now; the editor remembers the one it opened at. */
230
245
  @property({ type: Number }) revision = 0
231
246
  /** Narrow: the outline or the solid behind. */
@@ -237,7 +252,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
237
252
  if (!this.narrow) this.show = 'outline'
238
253
  })
239
254
 
240
- @state() private working: SectionWorking = { points: [], across: {}, length: 0 }
255
+ @state() private working: SectionWorking = { points: [], across: {}, length: 0, apex: null }
241
256
  @state() private selected: string | null = null
242
257
  @state() private note = ''
243
258
  private opened!: SectionWorking
@@ -250,10 +265,10 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
250
265
  private extent = 200
251
266
 
252
267
  private get axis(): Axis {
253
- return (this.body?.shape.extrusion?.axis ?? 'y') as Axis
268
+ return ((this.body && drawnOf(this.body.shape)?.axis) ?? 'y') as Axis
254
269
  }
255
270
  private get kind() {
256
- return this.body?.shape.extrusion?.section.kind
271
+ return this.body && drawnOf(this.body.shape)?.section.kind
257
272
  }
258
273
 
259
274
  connectedCallback() {
@@ -276,7 +291,12 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
276
291
  /** How far the drawing reaches from the origin: the outline's farthest point with room around, in 50 mm steps. */
277
292
  private extentOf(w: SectionWorking): number {
278
293
  const [h, v] = SCREEN[this.axis]
279
- const far = Math.max(1, ...w.points.flatMap(p => [Math.abs(p[h] ?? 0), Math.abs(p[v] ?? 0)]), ...Object.values(w.across).map(a => (a ?? 0) / 2))
294
+ const far = Math.max(
295
+ 1,
296
+ ...w.points.flatMap(p => [Math.abs(p[h] ?? 0), Math.abs(p[v] ?? 0)]),
297
+ ...Object.values(w.across).map(a => (a ?? 0) / 2),
298
+ ...Object.values(w.apex ?? {}).map(a => Math.abs(a ?? 0))
299
+ )
280
300
  return Math.ceil((far * 1.4 + 40) / 50) * 50
281
301
  }
282
302
 
@@ -336,12 +356,14 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
336
356
  const o = this.opened
337
357
  const changed = (a: number | undefined, b: number | undefined) => Math.abs((a ?? 0) - (b ?? 0)) > 1e-9
338
358
  const across = Object.fromEntries(Object.entries(w.across).filter(([k, v]) => changed(v, o.across[k as Axis])))
359
+ const apexMoved = !w.apex !== !o.apex || (!!w.apex && Object.entries(w.apex).some(([k, v]) => changed(v, o.apex![k as Axis])))
339
360
  return {
340
361
  kind: 'set-section',
341
362
  part: this.body!.id,
342
363
  ...(this.kind === 'polygon' ? { points: w.points.map(p => ({ ...p })) } : {}),
343
364
  ...(Object.keys(across).length ? { across } : {}),
344
- ...(changed(w.length, o.length) ? { length: w.length } : {})
365
+ ...(changed(w.length, o.length) ? { length: w.length } : {}),
366
+ ...(apexMoved ? { apex: w.apex ? { ...w.apex } : null } : {})
345
367
  }
346
368
  }
347
369
 
@@ -469,7 +491,9 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
469
491
  const handle = /^handle:(.)$/.exec(this.dragging)
470
492
  this.working = handle
471
493
  ? this.across(handle[1] as Axis, Math.max(1, 2 * Math.abs(handle[1] === h ? p.h : p.v)))
472
- : { ...this.working, points: movePoint(this.working.points, this.dragging, { [h]: p.h, [v]: p.v }) }
494
+ : this.dragging === 'apex'
495
+ ? { ...this.working, apex: { [h]: p.h, [v]: p.v } }
496
+ : { ...this.working, points: movePoint(this.working.points, this.dragging, { [h]: p.h, [v]: p.v }) }
473
497
  if (this.valid(this.working)) this.lastValid = this.working
474
498
  this.preview()
475
499
  }}
@@ -523,6 +547,22 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
523
547
  fill=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface)'}>${pointLabel(p.id)}</text>
524
548
  </g>`
525
549
  })}
550
+ ${w.apex
551
+ ? svg`${pts.map(p => svg`<line x1=${p[h]} y1=${p[v]} x2=${w.apex![h]} y2=${w.apex![v]} stroke="var(--md-sys-color-primary)" stroke-width=${e / 300} stroke-dasharray=${`${e / 60} ${e / 90}`} />`)}
552
+ <g apex style="cursor: grab"
553
+ @pointerdown=${(ev: PointerEvent) => {
554
+ ev.stopPropagation()
555
+ ;(ev.target as Element).setPointerCapture?.(ev.pointerId)
556
+ this.dragFrom = this.working
557
+ this.selected = null
558
+ this.dragging = 'apex'
559
+ }}>
560
+ <rect x=${w.apex[h]! - e / 34} y=${w.apex[v]! - e / 34} width=${e / 17} height=${e / 17}
561
+ transform=${`rotate(45 ${w.apex[h]} ${w.apex[v]})`} fill="var(--md-sys-color-tertiary)" stroke="var(--md-sys-color-surface)" stroke-width=${e / 250} />
562
+ <text x=${w.apex[h]! + e / 22} y=${w.apex[v]! + e / 12} font-size=${e / 16} font-weight="600" fill="var(--md-sys-color-on-surface)">${i18next.t('figure.label.section-editor-apex-mark')}</text>
563
+ <title>${i18next.t('figure.label.section-editor-apex-mark')}</title>
564
+ </g>`
565
+ : nothing}
526
566
  ${this.kind === 'polygon'
527
567
  ? nothing
528
568
  : ([h, v] as const).filter(sized).map(axis => {
@@ -571,6 +611,50 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
571
611
  </label>`
572
612
  }
573
613
 
614
+ /** A drawn section's end: the section again (a push) or one point (a pyramid), and where that point is. */
615
+ private apexFields() {
616
+ const [h, v] = SCREEN[this.axis]
617
+ const apex = this.working.apex
618
+ /* The kernel's refusal holds only for turning a push into a pyramid; a pyramid can always be made a push again. */
619
+ const blocked = !apex && !this.opened?.apex ? this.pointBlocked : ''
620
+ return html`<label apex>
621
+ <input
622
+ type="checkbox"
623
+ .checked=${live(!!apex)}
624
+ ?disabled=${!!blocked}
625
+ @change=${(ev: Event) => {
626
+ const on = (ev.target as HTMLInputElement).checked
627
+ this.change({ ...this.working, apex: on ? (this.opened.apex ?? apexOver(this.working.points, this.axis)) : null })
628
+ }}
629
+ />
630
+ <span>${i18next.t('figure.label.section-editor-apex')}</span>
631
+ ${blocked ? html`<small>${blocked}</small>` : nothing}
632
+ </label>
633
+ ${apex
634
+ ? ([h, v] as const).map(axis =>
635
+ html`<label mm>
636
+ <span>${i18next.t('figure.label.section-editor-apex-at', { axis: axis.toUpperCase() })}</span>
637
+ <input
638
+ type="number"
639
+ step="any"
640
+ inputmode="decimal"
641
+ aria-label=${i18next.t('figure.label.section-editor-apex-at', { axis: axis.toUpperCase() })}
642
+ .value=${live(String(apex[axis] ?? ''))}
643
+ @change=${(ev: Event) => {
644
+ const input = ev.target as HTMLInputElement
645
+ const value = Number(input.value)
646
+ if (input.value === '' || !Number.isFinite(value)) {
647
+ input.value = String(apex[axis] ?? '')
648
+ return
649
+ }
650
+ this.change({ ...this.working, apex: { ...apex, [axis]: value } })
651
+ }}
652
+ />
653
+ </label>`
654
+ )
655
+ : nothing}`
656
+ }
657
+
574
658
  private list(issues: ReturnType<typeof workingIssuesOf>['issues'], words: ReturnType<typeof workingIssuesOf>['words']) {
575
659
  const [h, v] = SCREEN[this.axis]
576
660
  const marked = markedBy(issues)
@@ -578,12 +662,13 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
578
662
  const linked = i18next.t('figure.text.section-editor-linked')
579
663
  return html`<div sizes>
580
664
  ${this.field(
581
- i18next.t('figure.label.section-editor-length'),
665
+ i18next.t(this.working.apex ? 'figure.label.section-editor-height' : 'figure.label.section-editor-length'),
582
666
  this.working.length,
583
667
  value => this.change({ ...this.working, length: value }),
584
668
  m?.[this.axis] !== 'editable',
585
669
  m?.[this.axis] === 'linked' ? linked : this.keptWords()
586
670
  )}
671
+ ${this.kind === 'polygon' ? this.apexFields() : nothing}
587
672
  ${this.kind === 'polygon'
588
673
  ? nothing
589
674
  : this.measures?.round
@@ -5,6 +5,7 @@ import { live } from 'lit/directives/live.js'
5
5
  import { i18next, localize } from '@operato/i18n'
6
6
  import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
7
7
  import { SidePanelStyles } from './side-panel-styles.js'
8
+ import { drawnOf } from './section-drawing.js'
8
9
  import {
9
10
  END_FACE,
10
11
  SIDE_FACE,
@@ -94,7 +95,8 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
94
95
 
95
96
  render() {
96
97
  const body = this.body
97
- const e = body?.shape?.provider === 'extrude' ? body.shape.extrusion : undefined
98
+ /* A push, or a pyramid: a drawn section drawn to one point, which has no corners and no inside. */
99
+ const e = body?.shape ? drawnOf(body.shape) : undefined
98
100
  if (!body || !e) return nothing
99
101
  const kind = e.section.kind
100
102
  return html`<section>
@@ -173,7 +173,14 @@ export function shapeKeyOf(body: FigureAssetGeometry | FigureGeometry): string {
173
173
  const e = shape.extrusion,
174
174
  c = shape.cavity,
175
175
  r = shape.revolution
176
- const meaning = e ? `${e.axis}:${e.section.kind}:${c ? `${c.start === 'open' ? 'o' : 'c'}${c.end === 'open' ? 'o' : 'c'}` : '-'}` : r ? `${r.axis}:${r.profile.kind}` : ''
176
+ const l = shape.loft
177
+ const meaning = e
178
+ ? `${e.axis}:${e.section.kind}:${c ? `${c.start === 'open' ? 'o' : 'c'}${c.end === 'open' ? 'o' : 'c'}` : '-'}`
179
+ : r
180
+ ? `${r.axis}:${r.profile.kind}`
181
+ : l
182
+ ? `${l.axis}:point`
183
+ : ''
177
184
  const segments = (body as { segments?: number }).segments ?? 12
178
185
  return `${shape.provider}|${meaning}|${dims}|s${segments}`
179
186
  }
@@ -30,6 +30,19 @@ export function paletteRecipe(kind: FigurePaletteKind, id: string): FigurePrimit
30
30
  { x: 0, z: 43 }
31
31
  ]
32
32
  }
33
+ case 'pyramid':
34
+ /* A tetrahedron: the drawn triangle's, drawn up to one point over its middle. */
35
+ return {
36
+ ...standing(100),
37
+ kind,
38
+ dimensions: { height: 100 },
39
+ points: [
40
+ { x: -50, z: -43 },
41
+ { x: 50, z: -43 },
42
+ { x: 0, z: 43 }
43
+ ],
44
+ apex: { x: 0, z: 0 }
45
+ }
33
46
  case 'plate':
34
47
  return { ...standing(10), kind: 'box', dimensions: { width: 600, height: 10, depth: 400 } }
35
48
  case 'wall':
@@ -104,11 +104,29 @@ export interface SectionWorking {
104
104
  /** Along each plane axis: a rectangle's size, an ellipse's diameter. Empty for a drawn section. */
105
105
  across: Partial<Record<Axis, number>>
106
106
  length: number
107
+ /** A drawn section's end drawn to one point (a pyramid), across the plane axes; null for a push. */
108
+ apex: Partial<Record<Axis, number>> | null
109
+ }
110
+
111
+ /** A pushed part's section, axis and ends -- or a pyramid's, whose end is its apex (figure-model `loft@1`). */
112
+ export function drawnOf(shape: FigureEvaluatedShape):
113
+ | { axis: Axis; section: FigureSection; start: number; end: number; apex: Partial<Record<Axis, number>> | null }
114
+ | undefined {
115
+ if (shape.extrusion) return { ...shape.extrusion, apex: null }
116
+ if (shape.loft) return { ...shape.loft, apex: shape.loft.apex }
117
+ return undefined
118
+ }
119
+
120
+ /** Where a new apex goes: over the middle of the points' span, whole millimetres. */
121
+ export function apexOver(points: Points, axis: Axis): Partial<Record<Axis, number>> {
122
+ const [u, v] = FIGURE_SECTION_PLANE[axis]
123
+ const mid = (x: Axis) => Math.round((Math.min(...points.map(p => p[x]!)) + Math.max(...points.map(p => p[x]!))) / 2)
124
+ return { [u]: mid(u), [v]: mid(v) }
107
125
  }
108
126
 
109
127
  /** The working state of a pushed part as it stands. */
110
128
  export function workingOf(shape: FigureEvaluatedShape): SectionWorking {
111
- const e = shape.extrusion!
129
+ const e = drawnOf(shape)!
112
130
  const s = e.section
113
131
  const [u, v] = FIGURE_SECTION_PLANE[e.axis]
114
132
  const across =
@@ -117,13 +135,14 @@ export function workingOf(shape: FigureEvaluatedShape): SectionWorking {
117
135
  : s.kind === 'ellipse'
118
136
  ? { [u]: 2 * s.radius[u]!, [v]: 2 * s.radius[v]! }
119
137
  : {}
120
- return { points: s.kind === 'polygon' ? s.points.map(p => ({ ...p })) : [], across, length: e.end - e.start }
138
+ return { points: s.kind === 'polygon' ? s.points.map(p => ({ ...p })) : [], across, length: e.end - e.start, apex: e.apex ? { ...e.apex } : null }
121
139
  }
122
140
 
123
141
  /** The section the working state makes, of the part's own kind: its corners kept, its points or widths the working ones. */
124
142
  export function sectionOf(shape: FigureEvaluatedShape, w: SectionWorking): FigureSection {
125
- const s = shape.extrusion!.section
126
- const [u, v] = FIGURE_SECTION_PLANE[shape.extrusion!.axis]
143
+ const e = drawnOf(shape)!
144
+ const s = e.section
145
+ const [u, v] = FIGURE_SECTION_PLANE[e.axis]
127
146
  if (s.kind === 'polygon') return { kind: 'polygon', points: w.points }
128
147
  if (s.kind === 'ellipse') return { kind: 'ellipse', radius: { [u]: w.across[u]! / 2, [v]: w.across[v]! / 2 } }
129
148
  return { kind: 'rectangle', size: { [u]: w.across[u]!, [v]: w.across[v]! }, ...(s.corner ? { corner: s.corner } : {}) }
@@ -134,11 +153,12 @@ export function workingIssuesOf(
134
153
  shape: FigureEvaluatedShape,
135
154
  w: SectionWorking
136
155
  ): { issues: FigureSectionIssue[]; words: Array<{ key: string; params: Record<string, string> }> } {
137
- const e = shape.extrusion!
156
+ const e = drawnOf(shape)!
138
157
  const section = sectionOf(shape, w)
139
158
  const issues = checkFigureSection(section, e.axis)
140
159
  const words = issues.map(i => issueWords(i, w.points, e.axis))
141
160
  if (!(Number.isFinite(w.length) && w.length > 0)) words.push({ key: 'figure.text.section-issue-length', params: {} })
161
+ if (w.apex && !Object.values(w.apex).every(n => Number.isFinite(n))) words.push({ key: 'figure.text.section-issue-number', params: {} })
142
162
  if (!issues.length && words.length === 0 && shape.cavity) {
143
163
  const made = figureExtrusionFeaturesOf({ axis: e.axis, section, start: e.start, end: e.start + w.length }, [shape.cavity])
144
164
  if ('refused' in made) {
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * What a person calls a part's shape (ADR-0100 §2.1): the starting shape it reads as -- 상자, 둥근 상자, 위가 열린 상자,
3
- * 원기둥, 문틀, 구, 원뿔, 그린 단면. A word for the screen, read from the shape node's own params; it is not a kind of
3
+ * 원기둥, 문틀, 구, 원뿔, 그린 단면, 뿔. A word for the screen, read from the shape node's own params; it is not a kind of
4
4
  * stored shape, and nothing is decided by it.
5
5
  */
6
- export type StartingShapeKind = 'cube' | 'rounded-box' | 'hollow-box' | 'cylinder' | 'portal' | 'sphere' | 'frustum' | 'polygon'
6
+ export type StartingShapeKind = 'cube' | 'rounded-box' | 'hollow-box' | 'cylinder' | 'portal' | 'sphere' | 'frustum' | 'polygon' | 'pyramid'
7
7
 
8
8
  export function startingShapeKindOf(shape: { op: string; params?: Record<string, unknown> } | undefined): StartingShapeKind | undefined {
9
9
  const p = shape?.params ?? {}
10
10
  if (shape?.op === 'revolve@1') return p.profile === 'taper' ? 'frustum' : 'sphere'
11
+ if (shape?.op === 'loft@1') return 'pyramid'
11
12
  if (shape?.op !== 'extrude@1') return undefined
12
13
  if (p.section === 'ellipse') return 'cylinder'
13
14
  if (p.section === 'polygon') return 'polygon'
@@ -20,7 +21,8 @@ export function startingShapeKindOf(shape: { op: string; params?: Record<string,
20
21
  * The same word for an evaluated body, when only the shape the kernel evaluated is in hand (a row of the parts list):
21
22
  * a door frame is the section with jambs, a drawn section the rest of the drawn ones.
22
23
  */
23
- export function startingShapeKindOfBody(shape: { provider?: string; dimensions?: Record<string, number>; extrusion?: { section: { kind: string; corner?: unknown } }; cavity?: unknown; revolution?: { profile: { kind: string } } } | undefined): StartingShapeKind | undefined {
24
+ export function startingShapeKindOfBody(shape: { provider?: string; dimensions?: Record<string, number>; extrusion?: { section: { kind: string; corner?: unknown } }; cavity?: unknown; revolution?: { profile: { kind: string } }; loft?: unknown } | undefined): StartingShapeKind | undefined {
25
+ if (shape?.loft) return 'pyramid'
24
26
  if (shape?.revolution) return shape.revolution.profile.kind === 'taper' ? 'frustum' : 'sphere'
25
27
  const s = shape?.extrusion?.section
26
28
  if (!s) return undefined
@@ -34,6 +34,6 @@ export declare const trackEdit: (clip: Clip, track: ClipTrack, patch?: Partial<P
34
34
  state: string;
35
35
  clip: string;
36
36
  keys: ClipKey[];
37
- loop: "hold" | "wrap";
37
+ loop: "wrap" | "hold";
38
38
  interpolation: "linear" | "step";
39
39
  };
@@ -295,6 +295,12 @@ export declare class FigureEditor extends LitElement {
295
295
  */
296
296
  /** The section editor over the canvas, the figure behind it drawing what the working points make. */
297
297
  private renderSectionEditor;
298
+ /**
299
+ * Why a drawn push's end cannot be drawn to one point now: the kernel's own refusal of that command on a copy (what is
300
+ * fastened to the end face), said as the face. Asked once per figure revision and part.
301
+ */
302
+ private pointBlocked?;
303
+ private pointBlockedOf;
298
304
  private renderStart;
299
305
  /**
300
306
  * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
@@ -32,6 +32,8 @@ import { SidePanelStyles } from './side-panel-styles.js';
32
32
  import { ScrollbarStyles } from '@operato/styles';
33
33
  import { alignChanges, boxOf, canMirror, mirrorChanges } from './align.js';
34
34
  import { paletteRecipe } from './palette.js';
35
+ import { apexOver, drawnOf } from './section-drawing.js';
36
+ import { END_FACE } from './section-controls.js';
35
37
  import { nudgeOf, placementOf } from './stage-model.js';
36
38
  import { roomRefitRowsOf } from './occupancy.js';
37
39
  import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js';
@@ -1824,21 +1826,40 @@ let FigureEditor = class FigureEditor extends LitElement {
1824
1826
  renderSectionEditor(geometry) {
1825
1827
  const part = geometry.find(g => ownerOf(g.id) === this.selected);
1826
1828
  /* A door frame's section is its own width, height, jamb and head: its fields edit it, not an outline. */
1827
- if (!part?.shape?.extrusion || part.shape.dimensions.jamb !== undefined)
1829
+ const drawn = part?.shape && drawnOf(part.shape);
1830
+ if (!part || !drawn || part.shape.dimensions.jamb !== undefined)
1828
1831
  return nothing;
1829
1832
  const asset = this.asset();
1830
1833
  const measures = figureSectionMeasuresOf(asset, this.selected);
1831
- const kept = figureScaleAnchorOf(asset, this.selected)?.[part.shape.extrusion.axis];
1834
+ const kept = figureScaleAnchorOf(asset, this.selected)?.[drawn.axis];
1832
1835
  return html `<figure-section-editor
1833
1836
  .body=${{ ...part, id: this.selected }}
1834
1837
  .measures=${measures}
1835
1838
  .kept=${kept}
1839
+ .pointBlocked=${this.pointBlockedOf(drawn)}
1836
1840
  .revision=${this.session?.read().revision ?? 0}
1837
1841
  @command=${(e) => this.linkCommand(e.detail.action)}
1838
1842
  @command-live=${(e) => this.holdAction(e.detail.action)}
1839
1843
  @section-editor-close=${() => this.closeSectionEditor()}
1840
1844
  ></figure-section-editor>`;
1841
1845
  }
1846
+ pointBlockedOf(drawn) {
1847
+ if (drawn.apex || drawn.section.kind !== 'polygon' || !this.session)
1848
+ return '';
1849
+ const key = `${this.session.read().revision}|${this.selected}`;
1850
+ if (this.pointBlocked?.key === key)
1851
+ return this.pointBlocked.words;
1852
+ let words = '';
1853
+ try {
1854
+ this.session.previewCommand({ kind: 'set-section', part: this.selected, apex: apexOver(drawn.section.points, drawn.axis) });
1855
+ }
1856
+ catch (error) {
1857
+ if (error.code === 'FEATURE_MAPPING_REQUIRED')
1858
+ words = i18next.t('figure.text.section-editor-apex-blocked', { face: i18next.t('figure.label.face-' + END_FACE(drawn.axis, 'end')) });
1859
+ }
1860
+ this.pointBlocked = { key, words };
1861
+ return words;
1862
+ }
1842
1863
  renderStart() {
1843
1864
  const shapes = ['box', 'cylinder', 'plate'];
1844
1865
  /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
@@ -2263,9 +2284,12 @@ let FigureEditor = class FigureEditor extends LitElement {
2263
2284
  renderSingle(name, field, unavailable) {
2264
2285
  /* A drawn section's point is called by its number and axis, as the section editor calls it (ADR-0100 §2.1, Q9). */
2265
2286
  const point = /^point\.p(\d+)\.([xyz])$/.exec(name);
2287
+ const apex = /^apex\.([xyz])$/.exec(name);
2266
2288
  const words = point
2267
2289
  ? [i18next.t('figure.label.section-editor-coordinate', { point: point[1], axis: point[2].toUpperCase() })]
2268
- : name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }));
2290
+ : apex
2291
+ ? [`${i18next.t('figure.label.section-editor-apex-mark')} ${apex[1].toUpperCase()}`]
2292
+ : name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }));
2269
2293
  return html `<span ?scrub=${!unavailable} @pointerdown=${(e) => this.scrub(field, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
2270
2294
  ><input
2271
2295
  aria-label=${field.id}