@things-factory/figure-ui 10.1.82 → 10.1.84

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 (83) hide show
  1. package/client/modeller/command-preview.ts +4 -2
  2. package/client/modeller/drawn-shapes.ts +29 -0
  3. package/client/modeller/editor.ts +11 -29
  4. package/client/modeller/figure-links.ts +47 -101
  5. package/client/modeller/figure-material-panel.ts +2 -3
  6. package/client/modeller/figure-parts.ts +9 -11
  7. package/client/modeller/figure-section-panel.ts +175 -0
  8. package/client/modeller/gizmo.ts +26 -16
  9. package/client/modeller/joint-fan.ts +4 -3
  10. package/client/modeller/links.ts +24 -24
  11. package/client/modeller/maturity.ts +12 -6
  12. package/client/modeller/motion-ends.ts +2 -1
  13. package/client/modeller/refusals.ts +20 -0
  14. package/client/modeller/renderer.ts +3 -2
  15. package/client/modeller/section-controls.ts +34 -0
  16. package/client/modeller/starting-shape.ts +34 -0
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/command-preview.js +4 -2
  19. package/dist-client/modeller/command-preview.js.map +1 -1
  20. package/dist-client/modeller/drawn-shapes.d.ts +44 -0
  21. package/dist-client/modeller/drawn-shapes.js +12 -0
  22. package/dist-client/modeller/drawn-shapes.js.map +1 -0
  23. package/dist-client/modeller/editor.d.ts +1 -6
  24. package/dist-client/modeller/editor.js +10 -29
  25. package/dist-client/modeller/editor.js.map +1 -1
  26. package/dist-client/modeller/figure-links.d.ts +2 -2
  27. package/dist-client/modeller/figure-links.js +45 -96
  28. package/dist-client/modeller/figure-links.js.map +1 -1
  29. package/dist-client/modeller/figure-material-panel.js +2 -3
  30. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  31. package/dist-client/modeller/figure-parts.d.ts +3 -2
  32. package/dist-client/modeller/figure-parts.js +6 -9
  33. package/dist-client/modeller/figure-parts.js.map +1 -1
  34. package/dist-client/modeller/figure-section-panel.d.ts +28 -0
  35. package/dist-client/modeller/figure-section-panel.js +172 -0
  36. package/dist-client/modeller/figure-section-panel.js.map +1 -0
  37. package/dist-client/modeller/gizmo.d.ts +3 -3
  38. package/dist-client/modeller/gizmo.js +24 -11
  39. package/dist-client/modeller/gizmo.js.map +1 -1
  40. package/dist-client/modeller/joint-fan.js +4 -3
  41. package/dist-client/modeller/joint-fan.js.map +1 -1
  42. package/dist-client/modeller/links.d.ts +4 -1
  43. package/dist-client/modeller/links.js +23 -25
  44. package/dist-client/modeller/links.js.map +1 -1
  45. package/dist-client/modeller/maturity.d.ts +3 -2
  46. package/dist-client/modeller/maturity.js +9 -6
  47. package/dist-client/modeller/maturity.js.map +1 -1
  48. package/dist-client/modeller/motion-ends.js +2 -1
  49. package/dist-client/modeller/motion-ends.js.map +1 -1
  50. package/dist-client/modeller/refusals.d.ts +0 -5
  51. package/dist-client/modeller/refusals.js +25 -0
  52. package/dist-client/modeller/refusals.js.map +1 -1
  53. package/dist-client/modeller/renderer.d.ts +3 -4
  54. package/dist-client/modeller/renderer.js +3 -2
  55. package/dist-client/modeller/renderer.js.map +1 -1
  56. package/dist-client/modeller/section-controls.d.ts +11 -0
  57. package/dist-client/modeller/section-controls.js +27 -0
  58. package/dist-client/modeller/section-controls.js.map +1 -0
  59. package/dist-client/modeller/starting-shape.d.ts +32 -0
  60. package/dist-client/modeller/starting-shape.js +37 -0
  61. package/dist-client/modeller/starting-shape.js.map +1 -0
  62. package/dist-client/route.d.ts +1 -1
  63. package/dist-client/tsconfig.tsbuildinfo +1 -1
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +5 -5
  66. package/test/browser/acceptance-page.js +8 -0
  67. package/test/command-preview.test.ts +3 -2
  68. package/test/fixtures/joint-column.json +110 -15
  69. package/test/fixtures/joint-roller.json +66 -9
  70. package/test/fixtures/robot-arm-6dof.json +203 -27
  71. package/test/joint-fan.test.ts +10 -7
  72. package/test/links.test.ts +6 -5
  73. package/test/material.test.ts +7 -2
  74. package/test/renderer.test.ts +2 -1
  75. package/test/resize-with-placement.test.ts +15 -14
  76. package/test/scene-canvas.test.ts +2 -1
  77. package/test/section-panel.test.ts +93 -0
  78. package/translations/en.json +21 -2
  79. package/translations/ja.json +20 -1
  80. package/translations/ko.json +22 -3
  81. package/translations/ms.json +20 -1
  82. package/translations/zh.json +20 -1
  83. package/dist-client/tsconfig.localkernel.tsbuildinfo +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { FigureBodyMove, FigureClipLoss, FigureFaceArea, FigureRowReading } from '@hatiolab/figure-model'
2
2
 
3
3
  import type { FigureRenderPart } from './renderer.js'
4
+ import { boxShape, discShape } from './drawn-shapes.js'
4
5
 
5
6
  /*
6
7
  * A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would
@@ -53,9 +54,10 @@ export function faceHighlightOf(face: FigureFaceArea | undefined): FigureRenderP
53
54
  /* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */
54
55
  const r = [0, 1, 2].map(i => [face.u[i]!, face.normal[i]!, face.v[i]!])
55
56
  const t = [0, 1, 2].map(i => face.centre[i]! + face.normal[i]! * (LIFT + THICKNESS / 2))
57
+ /* A plane touching a curved side is a line (Q3): drawn as a narrow strip, never a plate. */
56
58
  const shape: FigureRenderPart['shape'] = face.round
57
- ? { provider: 'cylinder', dimensions: { radius: face.size.u / 2, length: THICKNESS } }
58
- : { provider: 'box', dimensions: { width: face.size.u, height: THICKNESS, depth: face.size.v } }
59
+ ? discShape(face.size.u / 2, THICKNESS)
60
+ : boxShape(Math.max(face.size.u, THICKNESS), THICKNESS, Math.max(face.size.v, THICKNESS))
59
61
  /* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */
60
62
  return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } }
61
63
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shapes the modeller draws of its own -- a lit face, a joint's fan, a slide's line -- written as the shapes figure-model
3
+ * evaluates (ADR-0100): a section pushed along Y, centred. things-scene's one builder draws them as it draws a part.
4
+ */
5
+ export interface DrawnShape {
6
+ provider: 'extrude'
7
+ dimensions: Record<string, number>
8
+ extrusion: { axis: 'y'; section: DrawnSection; start: number; end: number }
9
+ }
10
+ type DrawnSection =
11
+ | { kind: 'rectangle'; size: { x: number; z: number } }
12
+ | { kind: 'ellipse'; radius: { x: number; z: number } }
13
+ | { kind: 'polygon'; points: Array<{ id: string; x: number; z: number }> }
14
+
15
+ const centred = (section: DrawnSection, height: number): DrawnShape => ({
16
+ provider: 'extrude',
17
+ dimensions: { length: height },
18
+ extrusion: { axis: 'y', section, start: -height / 2, end: height / 2 }
19
+ })
20
+
21
+ /** A box: width along X, height along Y, depth along Z. */
22
+ export const boxShape = (width: number, height: number, depth: number): DrawnShape => centred({ kind: 'rectangle', size: { x: width, z: depth } }, height)
23
+
24
+ /** A disc of a radius, `height` thick along Y. */
25
+ export const discShape = (radius: number, height: number): DrawnShape => centred({ kind: 'ellipse', radius: { x: radius, z: radius } }, height)
26
+
27
+ /** A section in X-Z, `height` thick along Y. */
28
+ export const sectionShape = (points: ReadonlyArray<{ x: number; z: number }>, height: number): DrawnShape =>
29
+ centred({ kind: 'polygon', points: points.map((p, i) => ({ id: `p${i + 1}`, x: p.x, z: p.z })) }, height)
@@ -6,16 +6,17 @@ import { notify } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, planFigureResize, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
- import type { FigureAsset, FigureAuthoringAction, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigureResizeRequest, FigurePlacement } from '@hatiolab/figure-model'
12
+ import type { FigureAsset, FigureAuthoringAction, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement } from '@hatiolab/figure-model'
13
13
  import type { FigureProductSession } from './product-session.js'
14
14
  import { captureFigureThumbnail } from './thumbnail.js'
15
15
  import { drawableFigureParts } from './renderer.js'
16
16
  import './canvas.js'
17
17
  import './figure-parts.js'
18
18
  import './figure-material-panel.js'
19
+ import './figure-section-panel.js'
19
20
  import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf, type HeldPreview } from './command-preview.js'
20
21
  import { motionEndsOf, type MotionEnds } from './motion-ends.js'
21
22
  import './figure-links.js'
@@ -39,7 +40,7 @@ import { nudgeOf, placementOf, type ScaleAnchor } from './stage-model.js'
39
40
  /** The numbers a rule gives a part's position, turn and size, per axis, for the cells that have no field. */
40
41
  type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
41
42
  import { roomRefitRowsOf } from './occupancy.js'
42
- import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, resizeRefusalOf, sizeRangeRefusalOf } from './refusals.js'
43
+ import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js'
43
44
  import type { FigureRenderPart } from './renderer.js'
44
45
  import type { FigureBudgetLine } from './maturity.js'
45
46
  import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
@@ -47,6 +48,7 @@ import { jointFanOf } from './joint-fan.js'
47
48
  import { jointDragOf } from './joint-drag.js'
48
49
  import type { FirstSave } from './first-save.js'
49
50
  import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
51
+ import { startingShapeKindOfBody } from './starting-shape.js'
50
52
 
51
53
 
52
54
  /**
@@ -1089,7 +1091,6 @@ export class FigureEditor extends LitElement {
1089
1091
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1090
1092
  @go-fasten=${() => this.goFasten()}
1091
1093
  @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
1092
- @resize-with-placement=${(e: CustomEvent) => this.resizeWithPlacement(e.detail.request)}
1093
1094
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1094
1095
  ></figure-links>`
1095
1096
  }
@@ -1137,30 +1138,6 @@ export class FigureEditor extends LitElement {
1137
1138
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1138
1139
  ></figure-clips>`
1139
1140
  }
1140
- /**
1141
- * "Follow the size and keep where the figure is placed" (ADR-0097): figure-model plans it from the figure as it is now,
1142
- * and the whole plan is one step of the history. A refusal with a way on -- move the part onto the plane, replace how
1143
- * its position is worked out -- asks in the app's prompt and plans again with that choice; any other says why.
1144
- */
1145
- private resizeWithPlacement(request: FigureResizeRequest) {
1146
- this.retry = undefined
1147
- this.notice = ''
1148
- const plan = planFigureResize(parseFigureAsset(this.session!.source()), request)
1149
- const refusal = plan.refusal
1150
- if (refusal) {
1151
- const choice = refusal.choice
1152
- /* The gap is chosen on the screen beside the button, with its distance; only replacing a position rule is asked here. */
1153
- this.retry = choice === 'replacePosition' ? { label: i18next.t('figure.button.resize-choice-replace-position'), run: () => this.resizeWithPlacement({ ...request, replacePosition: true }) } : undefined
1154
- this.error = resizeRefusalOf(plan, request.part, (key, params) => i18next.t(key, params))
1155
- return
1156
- }
1157
- this.run(() => this.session!.applyRecipe(plan.steps))
1158
- /*
1159
- The declared room was measured from the part's old numbers and is not changed: said as a fact, not as a claim that
1160
- the part leaves it -- that is measured where the room is declared again (designer 2026-09-27).
1161
- */
1162
- if (!this.error && plan.roomReads.length) this.notice = i18next.t('figure.text.resize-room-unchanged')
1163
- }
1164
1141
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
1165
1142
  private linkCommand(action: FigureAuthoringAction) {
1166
1143
  this.retry = undefined
@@ -1367,7 +1344,7 @@ export class FigureEditor extends LitElement {
1367
1344
  }
1368
1345
  rows.set(id, {
1369
1346
  id,
1370
- provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
1347
+ kind: startingShapeKindOfBody((g as { shape?: never }).shape) ?? '',
1371
1348
  hidden: this.hiddenParts.has(id),
1372
1349
  invisible: (g as { visible?: boolean }).visible === false,
1373
1350
  ...(id !== g.id ? { copies: 1 } : {}),
@@ -2037,6 +2014,11 @@ export class FigureEditor extends LitElement {
2037
2014
  ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
2038
2015
  ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
2039
2016
  ${this.renderLinks(unavailable, 'shape')}
2017
+ <figure-section-panel
2018
+ .body=${{ ...part, id: this.selected }}
2019
+ ?locked=${unavailable}
2020
+ @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
2021
+ ></figure-section-panel>
2040
2022
  <figure-material-panel
2041
2023
  .body=${{ ...part, id: this.selected }}
2042
2024
  .palette=${view.palette ?? {}}
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from 'lit'
4
4
  import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
- import { FIGURE_FACES, planFigureResize } from '@hatiolab/figure-model'
7
+ import { FIGURE_FACES } from '@hatiolab/figure-model'
8
8
  import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
9
9
 
10
10
  import {
@@ -42,7 +42,6 @@ import {
42
42
  fixedJointOf,
43
43
  rowOf,
44
44
  positionsWorkedOut,
45
- resizeAxesOf,
46
45
  fieldWordsOf,
47
46
  measureNow,
48
47
  type Alignment,
@@ -109,10 +108,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
109
108
  div[resize-now] div[row] { grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px; }
110
109
  div[resize-now] span[measure] { font-variant-numeric: tabular-nums; }
111
110
  button[link] { background: none; border: none; padding: 0; color: var(--md-sys-color-primary, #477b9e); text-decoration: underline; cursor: pointer; font: inherit; }
112
- /* What will be applied: a pending choice, boxed apart from the rows above. */
113
- fieldset[resize-pending] { display: grid; gap: 8px; margin: 12px 0; padding: 10px 12px; border: 1px dashed var(--border-color, #8888); border-radius: 6px; }
114
- fieldset[resize-pending] label { display: flex; align-items: center; gap: 6px; }
115
- fieldset[resize-pending] small { opacity: 0.7; }
111
+ /* Size behaviour is a choice about dimensions only. It never implies a placement or a fastening. */
112
+ details[resize-choice] { margin: 12px 0; padding: 0; border: 0; }
113
+ details[resize-choice] > div { display: grid; gap: 8px; padding: 8px 0; }
116
114
  div[resize-held] { display: grid; gap: 8px; margin: 12px 0; padding: 10px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
117
115
  div[resize-held] p { margin: 0; display: flex; align-items: center; gap: 6px; }
118
116
  p[fasten-mode] { margin: 0; font-weight: 600; }
@@ -170,8 +168,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
170
168
  private fastenCandidate?: { action: FigureAuthoringAction; face?: { part: string; face: string } }
171
169
  private rowCandidate?: FigureAuthoringAction
172
170
 
173
- /** The directions ticked for "follow the size, attached", per part, once the author has ticked or fixed any. */
174
- @state() private pendingAxes: Record<string, Axis[]> = {}
171
+ /** A new fastening starts with a target. Its face and alignment are asked only after that target is chosen. */
172
+ @state() private choosingFastening = false
175
173
  /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
176
174
  @state() private spotlit = false
177
175
  @state() private fasten = freshFasten()
@@ -198,6 +196,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
198
196
  this.place = freshPlace()
199
197
  this.row = freshRow()
200
198
  this.editingMotion = false
199
+ this.choosingFastening = false
201
200
  }
202
201
  }
203
202
 
@@ -317,41 +316,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
317
316
  }
318
317
 
319
318
  private renderDimensions(asset: FigureAsset) {
320
- /*
321
- The kernel's plan for "follow the size and keep where the figure is placed" (ADR-0097), with the directions the
322
- author ticked below -- a pending choice, drawn apart from the rows above, which only say what each direction does
323
- now (designer 2026-09-27: the immediate and the pending are not mixed in one look).
324
- */
325
319
  const editable = followableAxes(asset, this.partId)
326
- const choice = resizeAxesOf(asset, this.partId, this.pendingAxes[this.partId])
327
- const axes = choice.axes
328
- const plan = planFigureResize(asset, { part: this.partId, axes })
329
- const mode = plan.mode
330
320
  const round = editable.length === 2
331
321
  const word = (axis: Axis) => i18next.t(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
332
- const scope =
333
- axes.length && axes.length === editable.length
334
- ? i18next.t(`figure.text.resize-scope-all-${editable.length}`)
335
- : i18next.t('figure.text.resize-scope-only', { axes: axes.map(word).join(' · ') })
336
- const ask = (gap?: 'keep' | 'plane') =>
337
- this.dispatchEvent(new CustomEvent('resize-with-placement', { detail: { request: { part: this.partId, axes, ...(gap ? { gap } : {}) } }, bubbles: true, composed: true }))
338
- const gapChoice = plan.refusal?.code === 'GAP_CHOICE' ? plan.refusal : undefined
339
- /* Brought onto the plane, where it moves -- said on the button before it is pressed. */
340
- const onto = gapChoice ? planFigureResize(asset, { part: this.partId, axes, gap: 'plane' }).moves[0] : undefined
341
- const place = i18next.t(`figure.label.resize-place-${mode}`)
342
- const tick = (axis: Axis, on: boolean) => {
343
- const next = on ? [...new Set([...axes, axis])] : axes.filter(one => one !== axis)
344
- this.pendingAxes = { ...this.pendingAxes, [this.partId]: next }
345
- }
346
322
  const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
347
- /* One step for the whole part (figure-model follow-size): the size is declared from the parts if there is none. */
348
323
  const followsAll = editable.every(axis => followOf(asset, this.partId, axis).of === 'instance')
349
- const whole = followsAll
350
- ? nothing
351
- : html`<button act follow-size ?disabled=${this.locked} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
352
- <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
353
- </button>`
354
- const held = plan.refusal?.code === 'HELD'
355
324
  return html`<p hint>${i18next.t('figure.text.resize-intent')}</p>
356
325
  ${asset.sizing === 'uniform' ? html`<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
357
326
  <div resize-now>${editable.map(axis => {
@@ -366,46 +335,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
366
335
  ${following
367
336
  ? html`<button link resize-unfollow=${axis} ?disabled=${this.locked}
368
337
  @click=${() => {
369
- /* Set back to fixed: the author's choice for this part from now on, not a blank to fill with "all". */
370
- this.pendingAxes = { ...this.pendingAxes, [this.partId]: axes.filter(one => one !== axis) }
371
338
  this.follow(asset, axis, 'own', now)
372
339
  }}>${i18next.t('figure.button.resize-unfollow')}</button>`
373
340
  : nothing}
374
341
  </div>`
375
342
  })}</div>
376
- ${held
377
- ? this.renderHeld(asset)
378
- : html`<fieldset resize-pending ?disabled=${this.locked || asset.sizing === 'uniform'}>
379
- <legend>${i18next.t('figure.label.resize-pending')}</legend>
380
- ${editable.map(axis => {
381
- const now = followOf(asset, this.partId, axis)
382
- const following = now.of === 'instance' && now.axis === axis
383
- const custom = now.of !== 'own' && !following
384
- return html`<label>
385
- <input type="checkbox" resize-tick=${axis} .checked=${axes.includes(axis)} ?disabled=${following || custom}
386
- @change=${(e: Event) => tick(axis, (e.target as HTMLInputElement).checked)} />
387
- ${word(axis)}${following ? html` <small>${i18next.t('figure.text.resize-already-following')}</small>` : nothing}
388
- </label>`
389
- })}
390
- <p hint resize-basis>${i18next.t(`figure.text.resize-basis-${choice.basis}`)}</p>
391
- <p hint>${i18next.t('figure.text.placement-intent-' + mode)}</p>
392
- ${gapChoice
393
- ? html`<p hint gap-choice>${i18next.t(gapChoice.mm! > 0 ? 'figure.text.resize-gap-off' : 'figure.text.resize-gap-through', { place, mm: String(Math.abs(Math.round(gapChoice.mm! * 10) / 10)) })}</p>
394
- <button act resize-keep-gap @click=${() => ask('keep')}>
395
- ${i18next.t(gapChoice.mm! > 0 ? 'figure.button.resize-keep-gap' : 'figure.button.resize-keep-through', { scope, mm: String(Math.abs(Math.round(gapChoice.mm! * 10) / 10)) })}
396
- </button>
397
- <button act resize-onto-plane @click=${() => ask('plane')}>
398
- ${i18next.t('figure.button.resize-onto-plane-' + mode, { scope })}
399
- ${onto ? html`<small>${i18next.t('figure.text.resize-move', { axis: onto.axis.toUpperCase(), mm: String(Math.round((onto.to - onto.from) * 10) / 10) })}</small>` : nothing}
400
- </button>`
401
- : html`<button act resize-with-placement ?disabled=${choice.basis === 'none' || plan.refusal?.code === 'UNIFORM_POLICY'} @click=${() => ask()}>
402
- ${i18next.t('figure.button.resize-scoped-' + mode, { scope })}
403
- </button>`}
404
- ${!plan.refusal && plan.replaces.length
405
- ? html`<p hint resize-replaces>${i18next.t('figure.text.resize-replaces', { fields: [...new Set(plan.replaces.map(id => fieldWordsOf(id, key => i18next.t(key))))].join(' · '), place })}</p>`
406
- : nothing}
407
- </fieldset>`}
408
- <details><summary>${i18next.t('figure.label.resize-advanced')}</summary>${whole}${followableAxes(asset, this.partId).map(axis => {
343
+ ${followsAll
344
+ ? html`<p hint>${i18next.t('figure.text.resize-all-following')}</p>`
345
+ : html`<button act follow-size ?disabled=${this.locked || asset.sizing === 'uniform'} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
346
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
347
+ </button>`}
348
+ <details resize-choice><summary>${i18next.t('figure.label.resize-advanced')}</summary><div>${followableAxes(asset, this.partId).map(axis => {
409
349
  const now = followOf(asset, this.partId, axis)
410
350
  const value = choiceOf(now)
411
351
  /* A cylinder's two measures are its radius and its length; its X and Y name them. */
@@ -445,7 +385,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
445
385
  </label>`
446
386
  : nothing}
447
387
  </div>`
448
- })}</details>`
388
+ })}</div></details>`
449
389
  }
450
390
 
451
391
  /**
@@ -631,6 +571,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
631
571
  target: 'plane' in current.body0 ? i18next.t('figure.label.mounting-plane') : `${current.body0.part} · ${i18next.t(`figure.label.face-${current.body0.face}`)}`
632
572
  })
633
573
  : i18next.t('figure.text.fasten-new')
574
+ /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
575
+ if (!current && !this.choosingFastening && !f.target)
576
+ return html`
577
+ <p hint>${i18next.t('figure.text.fasten-start')}</p>
578
+ <button act ?disabled=${this.locked} @click=${() => (this.choosingFastening = true)}>
579
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
580
+ </button>
581
+ `
634
582
  return html`
635
583
  <p fasten-mode ?editing=${!!current}>${editing}</p>
636
584
  ${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
@@ -651,34 +599,32 @@ export class FigureLinks extends localize(i18next)(LitElement) {
651
599
  </div>
652
600
  ${this.renderDetachWords()}`
653
601
  : nothing}
654
- <div form>
655
- <span>${i18next.t('figure.label.my-face')}</span>
656
- <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as FigureFace | 'centre' })}>
657
- ${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
658
- </select>
659
- </div>
660
- <label toggle>
661
- <input type="checkbox" .checked=${f.toPlane} @change=${(e: Event) => this.aimAt(asset, { ...f, toPlane: (e.target as HTMLInputElement).checked })} />
662
- ${i18next.t('figure.label.stands-on-plane')}
663
- </label>
664
602
  ${f.toPlane
665
- ? nothing
603
+ ? html`<p hint>${i18next.t('figure.text.fasten-edit-on-plane')}</p>`
666
604
  : html`<div form>
667
- <span>${i18next.t('figure.label.target-part')}</span>
668
- <select @change=${(e: Event) => this.aimAt(asset, { ...f, target: (e.target as HTMLSelectElement).value })}>
669
- <option value="" ?selected=${!f.target}>—</option>
670
- ${targets.map(one =>
671
- loops(one)
672
- ? html`<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
673
- : html`<option value=${one} ?selected=${one === f.target}>${one}</option>`
674
- )}
675
- </select>
676
- <span>${i18next.t('figure.label.target-face')}</span>
677
- <select @change=${(e: Event) => this.aimAt(asset, { ...f, targetFace: (e.target as HTMLSelectElement).value as FigureFace })}>
678
- ${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
679
- </select>
680
- </div>`}
681
- <details fasten-detail>
605
+ <span>${i18next.t('figure.label.target-part')}</span>
606
+ <select @change=${(e: Event) => this.aimAt(asset, { ...f, target: (e.target as HTMLSelectElement).value })}>
607
+ <option value="" ?selected=${!f.target}>${i18next.t('figure.label.target-part-placeholder')}</option>
608
+ ${targets.map(one =>
609
+ loops(one)
610
+ ? html`<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
611
+ : html`<option value=${one} ?selected=${one === f.target}>${one}</option>`
612
+ )}
613
+ </select>
614
+ </div>
615
+ ${f.target
616
+ ? html`<div form>
617
+ <span>${i18next.t('figure.label.my-face')}</span>
618
+ <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as FigureFace | 'centre' })}>
619
+ ${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
620
+ </select>
621
+ <span>${i18next.t('figure.label.target-face')}</span>
622
+ <select @change=${(e: Event) => this.aimAt(asset, { ...f, targetFace: (e.target as HTMLSelectElement).value as FigureFace })}>
623
+ ${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
624
+ </select>
625
+ </div>`
626
+ : html`<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}`}
627
+ ${(f.toPlane || f.target) ? html`<details fasten-detail>
682
628
  <summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')}</span></summary>
683
629
  ${f.toPlane
684
630
  ? nothing
@@ -700,7 +646,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
700
646
  ${action && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
701
647
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
702
648
  <md-icon>link</md-icon>${i18next.t(current ? 'figure.button.fasten-update' : 'figure.button.fasten')}
703
- </button>
649
+ </button>` : nothing}
704
650
  `
705
651
  }
706
652
 
@@ -6,6 +6,7 @@ import { MATERIAL_PRESETS, SEGMENT_PRESETS, figureTrianglesOf } from '@hatiolab/
6
6
  import { activePalette } from '@hatiolab/things-scene'
7
7
  import type { FigureAssetGeometry, FigureMaterial, FigurePartEdit } from '@hatiolab/figure-model'
8
8
  import { SidePanelStyles } from './side-panel-styles.js'
9
+ import { isRoundBody } from './starting-shape.js'
9
10
 
10
11
  /** The purpose each palette role is for, by its token. */
11
12
  const PURPOSE: Record<string, string> = {
@@ -23,7 +24,6 @@ const PURPOSE: Record<string, string> = {
23
24
  export const purposeOf = (token?: string) => i18next.t(`figure.label.palette-purpose-${PURPOSE[token ?? ''] ?? 'registered'}`)
24
25
 
25
26
  /** Bodies with a rim — the ones `set-segments` accepts. */
26
- const ROUND = new Set(['cylinder', 'ellipticCylinder', 'sphere', 'frustum'])
27
27
  const DEFAULT_SEGMENTS = 12
28
28
 
29
29
  /**
@@ -230,8 +230,7 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
230
230
  }
231
231
 
232
232
  private renderSegments(part: FigureAssetGeometry & { id: string }) {
233
- const provider = (part as { shape?: { provider?: string } }).shape?.provider ?? ''
234
- if (!ROUND.has(provider)) return nothing
233
+ if (!isRoundBody((part as { shape?: never }).shape)) return nothing
235
234
  const current = part.segments ?? DEFAULT_SEGMENTS
236
235
  return html`
237
236
  <div field>
@@ -13,7 +13,7 @@ import { partLabelOf } from './fields.js'
13
13
  *
14
14
  * 팔레트는 목록 위에, 눈 단추는 늘 자리를 차지하고, 조작은 고른 줄에서만 보인다.
15
15
  *
16
- * 부품의 도형은 평가가 준 `provider` 로 읽는다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
16
+ * 부품의 도형은 평가가 준 형상에서 읽은 시작 모양 이름(`kind`)으로 보인다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
17
17
  * 보이고 안 보이는 것)와는 다른 것이다. 그것은 속성 패널의 몫이다.
18
18
  *
19
19
  * 이 컴포넌트는 정본을 만지지 않는다. 무엇을 하겠다는 것만 알린다.
@@ -22,7 +22,8 @@ import { partLabelOf } from './fields.js'
22
22
  /** 한 줄 — 평가에서 이 패널이 읽는 것만. */
23
23
  export interface FigurePartRow {
24
24
  id: string
25
- provider: string
25
+ /** 시작 모양 이름(`startingShapeKindOfBody`): 상자 · 원기둥 … 아이콘을 고른다. */
26
+ kind: string
26
27
  /** 작업대에서 잠깐 가린 것. 저장되지 않는다. */
27
28
  hidden?: boolean
28
29
  /** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
@@ -36,17 +37,14 @@ export interface FigurePartRow {
36
37
  fastenedTo?: string
37
38
  }
38
39
 
39
- /** 제공자마다의 아이콘. Material Symbols 이름이다. */
40
- const PROVIDER_ICON: Record<string, string> = {
41
- box: 'deployed_code',
40
+ /** 시작 모양마다의 아이콘. Material Symbols 이름이다. */
41
+ const KIND_ICON: Record<string, string> = {
42
+ cube: 'deployed_code',
42
43
  cylinder: 'database',
43
- ellipticCylinder: 'database',
44
44
  frustum: 'change_history',
45
45
  sphere: 'circle',
46
- roundedBox: 'crop_square',
47
- roundedBoxXZ: 'crop_square',
48
- hollowBox: 'check_box_outline_blank',
49
- hollowBoxXZ: 'check_box_outline_blank',
46
+ 'rounded-box': 'crop_square',
47
+ 'hollow-box': 'check_box_outline_blank',
50
48
  polygon: 'pentagon',
51
49
  portal: 'door_front'
52
50
  }
@@ -575,7 +573,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
575
573
  >
576
574
  <md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
577
575
  </button>
578
- <md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
576
+ <md-icon>${KIND_ICON[part.kind] ?? 'deployed_code'}</md-icon>
579
577
  <span label title=${part.id}
580
578
  >${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}${part.fastenedTo
581
579
  ? html`<small fastened-to title=${i18next.t('figure.label.fastened-to')}>${part.fastenedTo}</small>`
@@ -0,0 +1,175 @@
1
+ import { css, html, LitElement, nothing } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { live } from 'lit/directives/live.js'
4
+
5
+ import { i18next, localize } from '@operato/i18n'
6
+ import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
7
+ import { SidePanelStyles } from './side-panel-styles.js'
8
+ import { cavityRequestOf, defaultWallOf, mm, sameOf } from './section-controls.js'
9
+
10
+ type Axis = 'x' | 'y' | 'z'
11
+ type End = 'start' | 'end'
12
+
13
+ /** The face each end of a push is, by the axis it runs along: its start is the low side. */
14
+ const END_FACE: Record<Axis, Record<End, string>> = {
15
+ x: { start: 'left', end: 'right' },
16
+ y: { start: 'bottom', end: 'top' },
17
+ z: { start: 'back', end: 'front' }
18
+ }
19
+
20
+ /**
21
+ * 단면 — a pushed part's rounded corners and its inside (ADR-0100 stage 1).
22
+ *
23
+ * Only what the part's section has: corners for a rectangle, an inside for a rectangle or an ellipse. A drawn section
24
+ * and a turned body show nothing here. A radius or a wall entered sets every corner or every wall to it; the kernel
25
+ * holds per-axis values, and when they differ the field says so and shows none of them.
26
+ *
27
+ * Every change leaves as one `command`; the editor runs it through the session, one step of the undo history.
28
+ */
29
+ @customElement('figure-section-panel')
30
+ export class FigureSectionPanel extends localize(i18next)(LitElement) {
31
+ static styles = [SidePanelStyles, css`
32
+ :host {
33
+ display: block;
34
+ }
35
+ div[row] {
36
+ display: flex;
37
+ gap: 6px;
38
+ align-items: center;
39
+ }
40
+ div[row] > * {
41
+ flex: 1;
42
+ min-width: 0;
43
+ }
44
+ div[row] > label {
45
+ flex: 0 0 auto;
46
+ }
47
+ `]
48
+
49
+ @property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
50
+ @property({ type: Boolean }) locked = false
51
+
52
+ render() {
53
+ const body = this.body
54
+ const extrusion = body?.shape?.provider === 'extrude' ? body.shape.extrusion : undefined
55
+ if (!body || !extrusion) return nothing
56
+ const kind = extrusion.section.kind
57
+ if (kind !== 'rectangle' && kind !== 'ellipse') return nothing
58
+ return html`<section>
59
+ <h3>${i18next.t('figure.label.group-section')}</h3>
60
+ ${kind === 'rectangle' ? this.renderCorner(body) : nothing} ${this.renderCavity(body, extrusion.axis)}
61
+ </section>`
62
+ }
63
+
64
+ private renderCorner(body: { id: string; shape: FigureEvaluatedShape }) {
65
+ const d = body.shape.dimensions
66
+ const reaches = Object.keys(d)
67
+ .filter(k => k.startsWith('corner.'))
68
+ .map(k => d[k])
69
+ const one = reaches.length ? sameOf(reaches) : 0
70
+ return html`<div field>
71
+ <label>${i18next.t('figure.label.section-corner')}</label>
72
+ <input
73
+ type="number"
74
+ min="0"
75
+ step="any"
76
+ .value=${live(one === undefined ? '' : String(mm(one)))}
77
+ placeholder=${one === undefined ? i18next.t('figure.text.section-corner-differs') : ''}
78
+ ?disabled=${this.locked}
79
+ @change=${(e: Event) => {
80
+ const input = e.target as HTMLInputElement
81
+ const value = Number(input.value)
82
+ if (input.value === '' || !Number.isFinite(value) || value < 0) {
83
+ input.value = one === undefined ? '' : String(mm(one))
84
+ return
85
+ }
86
+ /* 0 is a sharp corner: the rounding is taken away rather than kept at nothing. */
87
+ this.command({ kind: 'set-section-corner', part: body.id, corner: value > 0 ? value : null })
88
+ }}
89
+ />
90
+ </div>`
91
+ }
92
+
93
+ private renderCavity(body: { id: string; shape: FigureEvaluatedShape }, axis: Axis) {
94
+ const cavity = body.shape.cavity
95
+ const request = (patch: Partial<FigureCavityRequest>) => cavityRequestOf(body.shape, patch)
96
+ return html`<div field>
97
+ <label>
98
+ <input
99
+ type="checkbox"
100
+ .checked=${live(!!cavity)}
101
+ ?disabled=${this.locked}
102
+ @change=${(e: Event) => {
103
+ const on = (e.target as HTMLInputElement).checked
104
+ ;(e.target as HTMLInputElement).checked = !!cavity
105
+ this.command({ kind: 'set-cavity', part: body.id, cavity: on ? request({}) : null })
106
+ }}
107
+ />
108
+ ${i18next.t('figure.label.section-cavity')}
109
+ </label>
110
+ </div>
111
+ ${cavity
112
+ ? html`<div field>
113
+ <label>${i18next.t('figure.label.section-wall')}</label>
114
+ ${this.thickness(sameOf(Object.values(cavity.wall)), value => this.command({ kind: 'set-cavity', part: body.id, cavity: request({ wall: value }) }), 'figure.text.section-wall-differs')}
115
+ </div>
116
+ ${(['start', 'end'] as const).map(end => this.renderEnd(body.id, axis, end, cavity[end], request))}`
117
+ : nothing}`
118
+ }
119
+
120
+ private renderEnd(
121
+ part: string,
122
+ axis: Axis,
123
+ end: End,
124
+ now: 'open' | { closed: number },
125
+ request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest
126
+ ) {
127
+ const closed = now !== 'open'
128
+ const set = (value: 'open' | { closed: number }) => this.command({ kind: 'set-cavity', part, cavity: request({ [end]: value }) })
129
+ return html`<div field>
130
+ <label>${i18next.t('figure.label.face-' + END_FACE[axis][end])}</label>
131
+ <div row>
132
+ <select
133
+ .value=${live(closed ? 'closed' : 'open')}
134
+ ?disabled=${this.locked}
135
+ @change=${(e: Event) => {
136
+ const select = e.target as HTMLSelectElement
137
+ const want = select.value
138
+ select.value = closed ? 'closed' : 'open'
139
+ set(want === 'open' ? 'open' : { closed: defaultWallOf(this.body!.shape) })
140
+ }}
141
+ >
142
+ <option value="closed">${i18next.t('figure.label.section-end-closed')}</option>
143
+ <option value="open">${i18next.t('figure.label.section-end-open')}</option>
144
+ </select>
145
+ ${closed ? this.thickness(now.closed, value => set({ closed: value })) : html`<span></span>`}
146
+ </div>
147
+ </div>`
148
+ }
149
+
150
+ /** A thickness field: a positive number, committed on change; anything else puts the old value back. */
151
+ private thickness(now: number | undefined, commit: (value: number) => void, differs?: string) {
152
+ const shown = now === undefined ? '' : String(mm(now))
153
+ return html`<input
154
+ type="number"
155
+ min="0"
156
+ step="any"
157
+ .value=${live(shown)}
158
+ placeholder=${now === undefined && differs ? i18next.t(differs) : ''}
159
+ ?disabled=${this.locked}
160
+ @change=${(e: Event) => {
161
+ const input = e.target as HTMLInputElement
162
+ const value = Number(input.value)
163
+ if (input.value === '' || !Number.isFinite(value) || !(value > 0)) {
164
+ input.value = shown
165
+ return
166
+ }
167
+ commit(value)
168
+ }}
169
+ />`
170
+ }
171
+
172
+ private command(action: FigureAuthoringAction) {
173
+ this.dispatchEvent(new CustomEvent('command', { detail: { action }, bubbles: true, composed: true }))
174
+ }
175
+ }