@things-factory/figure-ui 10.1.84 → 10.1.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/modeller/editor.ts +46 -2
- package/client/modeller/figure-parts.ts +4 -4
- package/client/modeller/figure-section-editor.ts +484 -0
- package/client/modeller/figure-section-panel.ts +211 -75
- package/client/modeller/live.ts +13 -4
- package/client/modeller/section-controls.ts +89 -8
- package/client/modeller/section-drawing.ts +97 -0
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +8 -0
- package/dist-client/modeller/editor.js +51 -1
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-parts.js +1 -3
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +62 -0
- package/dist-client/modeller/figure-section-editor.js +495 -0
- package/dist-client/modeller/figure-section-editor.js.map +1 -0
- package/dist-client/modeller/figure-section-panel.d.ts +26 -12
- package/dist-client/modeller/figure-section-panel.js +193 -69
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/live.d.ts +3 -1
- package/dist-client/modeller/live.js +13 -3
- package/dist-client/modeller/live.js.map +1 -1
- package/dist-client/modeller/section-controls.d.ts +33 -0
- package/dist-client/modeller/section-controls.js +71 -7
- package/dist-client/modeller/section-controls.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +33 -0
- package/dist-client/modeller/section-drawing.js +82 -0
- package/dist-client/modeller/section-drawing.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/test/browser/acceptance-page.js +7 -0
- package/test/palette-icons.test.ts +1 -1
- package/test/parts-wiring.test.ts +6 -2
- package/test/section-editor.test.ts +192 -0
- package/test/section-panel.test.ts +108 -6
- package/translations/en.json +37 -2
- package/translations/ja.json +37 -2
- package/translations/ko.json +37 -2
- package/translations/ms.json +37 -2
- package/translations/zh.json +37 -2
|
@@ -9,7 +9,7 @@ import { live } from 'lit/directives/live.js'
|
|
|
9
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, FigurePlacement } from '@hatiolab/figure-model'
|
|
12
|
+
import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, 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'
|
|
@@ -17,6 +17,7 @@ import './canvas.js'
|
|
|
17
17
|
import './figure-parts.js'
|
|
18
18
|
import './figure-material-panel.js'
|
|
19
19
|
import './figure-section-panel.js'
|
|
20
|
+
import './figure-section-editor.js'
|
|
20
21
|
import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf, type HeldPreview } from './command-preview.js'
|
|
21
22
|
import { motionEndsOf, type MotionEnds } from './motion-ends.js'
|
|
22
23
|
import './figure-links.js'
|
|
@@ -453,6 +454,8 @@ export class FigureEditor extends LitElement {
|
|
|
453
454
|
@state() private hiddenParts = new Set<string>()
|
|
454
455
|
/** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
|
|
455
456
|
@state() private live: LiveValues = NOTHING_LIVE
|
|
457
|
+
/** The part whose drawn section is open in the section editor (ADR-0100 §3.2), or null. */
|
|
458
|
+
@state() private sectionEditing: string | null = null
|
|
456
459
|
/**
|
|
457
460
|
* A command seen before it is done (ADR-0097 §5.5): worked out on a copy for the figure, version, part and command it
|
|
458
461
|
* names, and shown only while all four are still the ones on screen. Nothing of it is saved or undone.
|
|
@@ -491,6 +494,11 @@ export class FigureEditor extends LitElement {
|
|
|
491
494
|
private onKey = (e: KeyboardEvent) => {
|
|
492
495
|
const view = this.session?.read()
|
|
493
496
|
if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
|
|
497
|
+
/*
|
|
498
|
+
* The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
|
|
499
|
+
* Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
|
|
500
|
+
*/
|
|
501
|
+
if (this.sectionEditing) return
|
|
494
502
|
const canvas = this.renderRoot.querySelector<FigureCanvas>('figure-canvas')
|
|
495
503
|
const path = e.composedPath()
|
|
496
504
|
const action = keyActionOf(
|
|
@@ -637,6 +645,11 @@ export class FigureEditor extends LitElement {
|
|
|
637
645
|
}
|
|
638
646
|
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
639
647
|
if (_changed.has('selected')) this.gizmoMode = undefined
|
|
648
|
+
/*
|
|
649
|
+
* The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
|
|
650
|
+
* own Esc, the part removed, another figure opened -- it closes and lets go of the preview it held.
|
|
651
|
+
*/
|
|
652
|
+
if (this.sectionEditing && this.sectionEditing !== this.selected) this.closeSectionEditor()
|
|
640
653
|
}
|
|
641
654
|
/** The scene toggles: the mode already shown takes the gizmo away. */
|
|
642
655
|
private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
|
|
@@ -703,6 +716,16 @@ export class FigureEditor extends LitElement {
|
|
|
703
716
|
private hold(values: Record<string, number>, edit?: FigurePartEdit) {
|
|
704
717
|
this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
|
|
705
718
|
}
|
|
719
|
+
private closeSectionEditor() {
|
|
720
|
+
this.sectionEditing = null
|
|
721
|
+
this.holdAction(null)
|
|
722
|
+
}
|
|
723
|
+
/** A command in hand, drawn and not run; `null` lets go of it and the figure as it stands is drawn again. */
|
|
724
|
+
private holdAction(action: FigureAuthoringAction | null) {
|
|
725
|
+
const { action: _, ...rest } = this.live
|
|
726
|
+
this.live = action ? { ...rest, action } : rest
|
|
727
|
+
}
|
|
728
|
+
|
|
706
729
|
private changed() {
|
|
707
730
|
this.requestUpdate()
|
|
708
731
|
this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }))
|
|
@@ -1140,6 +1163,8 @@ export class FigureEditor extends LitElement {
|
|
|
1140
1163
|
}
|
|
1141
1164
|
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
1142
1165
|
private linkCommand(action: FigureAuthoringAction) {
|
|
1166
|
+
/* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
|
|
1167
|
+
this.holdAction(null)
|
|
1143
1168
|
this.retry = undefined
|
|
1144
1169
|
this.notice = ''
|
|
1145
1170
|
const hadSize = this.hasSize()
|
|
@@ -1756,6 +1781,18 @@ export class FigureEditor extends LitElement {
|
|
|
1756
1781
|
* recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
|
|
1757
1782
|
* mounting face and the tags are not asked for first.
|
|
1758
1783
|
*/
|
|
1784
|
+
/** The section editor over the canvas, the figure behind it drawing what the working points make. */
|
|
1785
|
+
private renderSectionEditor(geometry: readonly { id: string }[]) {
|
|
1786
|
+
const part = geometry.find(g => ownerOf(g.id) === this.selected) as { id: string; shape: FigureEvaluatedShape } | undefined
|
|
1787
|
+
if (!part?.shape?.extrusion || part.shape.extrusion.section.kind !== 'polygon') return nothing
|
|
1788
|
+
return html`<figure-section-editor
|
|
1789
|
+
.body=${{ ...part, id: this.selected }}
|
|
1790
|
+
.revision=${this.session?.read().revision ?? 0}
|
|
1791
|
+
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
1792
|
+
@command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
|
|
1793
|
+
@section-editor-close=${() => this.closeSectionEditor()}
|
|
1794
|
+
></figure-section-editor>`
|
|
1795
|
+
}
|
|
1759
1796
|
private renderStart() {
|
|
1760
1797
|
const shapes: FigurePaletteKind[] = ['box', 'cylinder', 'plate']
|
|
1761
1798
|
/* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
|
|
@@ -1965,6 +2002,7 @@ export class FigureEditor extends LitElement {
|
|
|
1965
2002
|
></figure-preview>`
|
|
1966
2003
|
: html`<div middle>
|
|
1967
2004
|
${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
|
|
2005
|
+
${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
|
|
1968
2006
|
<figure-canvas
|
|
1969
2007
|
?startShown=${view.geometry.length === 0 && !unavailable}
|
|
1970
2008
|
.view=${this.view}
|
|
@@ -2018,6 +2056,8 @@ export class FigureEditor extends LitElement {
|
|
|
2018
2056
|
.body=${{ ...part, id: this.selected }}
|
|
2019
2057
|
?locked=${unavailable}
|
|
2020
2058
|
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
2059
|
+
@command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
|
|
2060
|
+
@section-editor-open=${() => (this.sectionEditing = this.selected)}
|
|
2021
2061
|
></figure-section-panel>
|
|
2022
2062
|
<figure-material-panel
|
|
2023
2063
|
.body=${{ ...part, id: this.selected }}
|
|
@@ -2186,7 +2226,11 @@ export class FigureEditor extends LitElement {
|
|
|
2186
2226
|
if (agreed) this.linkCommand(refit.step)
|
|
2187
2227
|
}
|
|
2188
2228
|
private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
|
|
2189
|
-
|
|
2229
|
+
/* A drawn section's point is called by its number and axis, as the section editor calls it (ADR-0100 §2.1, Q9). */
|
|
2230
|
+
const point = /^point\.p(\d+)\.([xyz])$/.exec(name)
|
|
2231
|
+
const words = point
|
|
2232
|
+
? [i18next.t('figure.label.section-editor-coordinate', { point: point[1]!, axis: point[2]!.toUpperCase() })]
|
|
2233
|
+
: name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
|
|
2190
2234
|
return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
|
|
2191
2235
|
><input
|
|
2192
2236
|
aria-label=${field.id}
|
|
@@ -51,20 +51,20 @@ const KIND_ICON: Record<string, string> = {
|
|
|
51
51
|
|
|
52
52
|
/** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
|
|
53
53
|
/*
|
|
54
|
-
The palette. A plate and a wall are boxes of a shape a person reaches for, so they are
|
|
54
|
+
The palette: starting shapes (ADR-0100 §2.1). A plate and a wall are boxes of a shape a person reaches for, so they are
|
|
55
|
+
starting sizes, not kinds of their own. A rounded box and a box open at the top are not here: they are a box with its
|
|
56
|
+
corners rounded or an inside made, from the part's own 단면 section (§3.3), so the palette does not offer a second way.
|
|
55
57
|
*/
|
|
56
58
|
export type FigurePaletteKind = FigurePrimitiveCreation['kind'] | 'plate' | 'wall'
|
|
57
59
|
const RECIPES: Array<{ kind: FigurePaletteKind; icon: string; label: string }> = [
|
|
58
60
|
{ kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
|
|
59
61
|
{ kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
|
|
60
|
-
{ kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rounded-box' },
|
|
61
62
|
{ kind: 'sphere', icon: 'circle', label: 'figure.label.primitive-sphere' },
|
|
62
63
|
{ kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
|
|
63
64
|
{ kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
|
|
64
65
|
{ kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
|
|
65
66
|
{ kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
|
|
66
|
-
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
67
|
-
{ kind: 'hollow-box', icon: 'inventory_2', label: 'figure.label.primitive-hollow-box' }
|
|
67
|
+
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
@customElement('figure-parts')
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { css, html, LitElement, nothing, svg } from 'lit'
|
|
2
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
import { live } from 'lit/directives/live.js'
|
|
4
|
+
|
|
5
|
+
import { i18next, localize } from '@operato/i18n'
|
|
6
|
+
import { figureSectionSidesOf } from '@hatiolab/figure-model'
|
|
7
|
+
import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSectionPoint } from '@hatiolab/figure-model'
|
|
8
|
+
import { SidePanelStyles } from './side-panel-styles.js'
|
|
9
|
+
import { extentOf, insertAfter, issueWords, issuesOf, markedBy, movePoint, pointLabel, removePoint, type Points } from './section-drawing.js'
|
|
10
|
+
|
|
11
|
+
type Axis = 'x' | 'y' | 'z'
|
|
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
|
+
/** The editor's width and room for the solid beside it: below this the two take turns. */
|
|
17
|
+
export const NARROW_BELOW = 900
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 단면 편집 — a drawn section edited point by point (ADR-0100 §3.2, stage 2).
|
|
21
|
+
*
|
|
22
|
+
* The outline is drawn flat, looking along the push, in millimetres from the part's origin; the figure behind stays in
|
|
23
|
+
* view and shows the solid the working points make (`command-live`, the same preview the section panel uses), so the 2D
|
|
24
|
+
* and the 3D stand side by side. A point is dragged or typed; a side pressed gains a point in its middle; a point is
|
|
25
|
+
* deleted by its button or the Delete key, never below three. What the kernel would refuse is marked on the outline and
|
|
26
|
+
* said in the list; apply is held back, and the solid shows the last valid shape, which the caption names.
|
|
27
|
+
*
|
|
28
|
+
* Apply sends one `command` (one step of the history); cancel or Esc sends nothing and lets the preview go. The undo
|
|
29
|
+
* here is the editor's own, for the working points only. If the figure changes while the editor is open, the working
|
|
30
|
+
* points are no longer of the figure as it is, and apply is refused until the editor is opened again.
|
|
31
|
+
*/
|
|
32
|
+
@customElement('figure-section-editor')
|
|
33
|
+
export class FigureSectionEditor extends localize(i18next)(LitElement) {
|
|
34
|
+
static styles = [SidePanelStyles, css`
|
|
35
|
+
:host {
|
|
36
|
+
position: absolute;
|
|
37
|
+
left: 12px;
|
|
38
|
+
top: 12px;
|
|
39
|
+
bottom: 12px;
|
|
40
|
+
z-index: 5;
|
|
41
|
+
display: grid;
|
|
42
|
+
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
43
|
+
width: min(560px, calc(100% - 24px));
|
|
44
|
+
background: var(--md-sys-color-surface);
|
|
45
|
+
color: var(--md-sys-color-on-surface);
|
|
46
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
47
|
+
border-radius: 12px;
|
|
48
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
}
|
|
51
|
+
/* Each in its own row: the tabs are hidden on a wide screen, and without the rows named the body fell into theirs. */
|
|
52
|
+
header {
|
|
53
|
+
grid-row: 1;
|
|
54
|
+
}
|
|
55
|
+
nav[tabs] {
|
|
56
|
+
grid-row: 2;
|
|
57
|
+
}
|
|
58
|
+
div[body] {
|
|
59
|
+
grid-row: 3;
|
|
60
|
+
}
|
|
61
|
+
footer {
|
|
62
|
+
grid-row: 4;
|
|
63
|
+
}
|
|
64
|
+
header {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: baseline;
|
|
67
|
+
gap: 10px;
|
|
68
|
+
padding: 10px 14px 4px;
|
|
69
|
+
}
|
|
70
|
+
header h2 {
|
|
71
|
+
margin: 0;
|
|
72
|
+
font-size: var(--ui-t-body);
|
|
73
|
+
}
|
|
74
|
+
header span {
|
|
75
|
+
font-size: 0.72rem;
|
|
76
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
77
|
+
}
|
|
78
|
+
nav[tabs] {
|
|
79
|
+
display: none;
|
|
80
|
+
gap: 4px;
|
|
81
|
+
padding: 0 14px 6px;
|
|
82
|
+
}
|
|
83
|
+
nav[tabs] button,
|
|
84
|
+
footer button,
|
|
85
|
+
li button {
|
|
86
|
+
font: inherit;
|
|
87
|
+
font-size: 0.78rem;
|
|
88
|
+
min-height: 30px;
|
|
89
|
+
padding: 0 12px;
|
|
90
|
+
border-radius: 15px;
|
|
91
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
92
|
+
background: var(--md-sys-color-surface-container-lowest);
|
|
93
|
+
color: var(--md-sys-color-on-surface);
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
nav[tabs] button[on] {
|
|
97
|
+
background: var(--md-sys-color-secondary-container);
|
|
98
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
99
|
+
}
|
|
100
|
+
div[body] {
|
|
101
|
+
display: grid;
|
|
102
|
+
grid-template-rows: minmax(160px, 1fr) auto;
|
|
103
|
+
min-height: 0;
|
|
104
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
105
|
+
}
|
|
106
|
+
div[outline] {
|
|
107
|
+
position: relative;
|
|
108
|
+
min-height: 0;
|
|
109
|
+
}
|
|
110
|
+
svg {
|
|
111
|
+
width: 100%;
|
|
112
|
+
height: 100%;
|
|
113
|
+
display: block;
|
|
114
|
+
touch-action: none;
|
|
115
|
+
user-select: none;
|
|
116
|
+
}
|
|
117
|
+
div[caption] {
|
|
118
|
+
position: absolute;
|
|
119
|
+
left: 8px;
|
|
120
|
+
top: 6px;
|
|
121
|
+
font-size: 0.68rem;
|
|
122
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
123
|
+
pointer-events: none;
|
|
124
|
+
}
|
|
125
|
+
div[caption][last-valid] {
|
|
126
|
+
color: var(--md-sys-color-error);
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
}
|
|
129
|
+
aside {
|
|
130
|
+
max-height: 34vh;
|
|
131
|
+
overflow: auto;
|
|
132
|
+
padding: 8px 14px;
|
|
133
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
134
|
+
}
|
|
135
|
+
ol[points] {
|
|
136
|
+
list-style: none;
|
|
137
|
+
margin: 0;
|
|
138
|
+
padding: 0;
|
|
139
|
+
display: grid;
|
|
140
|
+
gap: 4px;
|
|
141
|
+
}
|
|
142
|
+
ol[points] li {
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: 2.2em minmax(0, 1fr) minmax(0, 1fr) auto;
|
|
145
|
+
gap: 6px;
|
|
146
|
+
align-items: center;
|
|
147
|
+
padding: 2px 4px;
|
|
148
|
+
border-radius: 6px;
|
|
149
|
+
}
|
|
150
|
+
ol[points] li[selected] {
|
|
151
|
+
background: var(--md-sys-color-secondary-container);
|
|
152
|
+
}
|
|
153
|
+
ol[points] li[marked] b {
|
|
154
|
+
color: var(--md-sys-color-error);
|
|
155
|
+
}
|
|
156
|
+
ol[points] input {
|
|
157
|
+
min-width: 0;
|
|
158
|
+
font: inherit;
|
|
159
|
+
}
|
|
160
|
+
ul[issues] {
|
|
161
|
+
margin: 8px 0 0;
|
|
162
|
+
padding: 0 0 0 16px;
|
|
163
|
+
color: var(--md-sys-color-error);
|
|
164
|
+
font-size: 0.76rem;
|
|
165
|
+
}
|
|
166
|
+
small {
|
|
167
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
168
|
+
}
|
|
169
|
+
footer {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
gap: 8px;
|
|
173
|
+
padding: 8px 14px;
|
|
174
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
175
|
+
}
|
|
176
|
+
footer span[grow] {
|
|
177
|
+
flex: 1;
|
|
178
|
+
font-size: 0.72rem;
|
|
179
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
180
|
+
}
|
|
181
|
+
footer span[grow][stale] {
|
|
182
|
+
color: var(--md-sys-color-error);
|
|
183
|
+
}
|
|
184
|
+
footer button[primary] {
|
|
185
|
+
background: var(--md-sys-color-primary);
|
|
186
|
+
color: var(--md-sys-color-on-primary);
|
|
187
|
+
border-color: transparent;
|
|
188
|
+
}
|
|
189
|
+
button:disabled {
|
|
190
|
+
opacity: 0.45;
|
|
191
|
+
cursor: default;
|
|
192
|
+
}
|
|
193
|
+
/* Too little room beside the editor for the solid: the outline and the solid take turns (§7). */
|
|
194
|
+
:host([narrow]) nav[tabs] {
|
|
195
|
+
display: flex;
|
|
196
|
+
}
|
|
197
|
+
:host([narrow][show='solid']) div[body] {
|
|
198
|
+
display: none;
|
|
199
|
+
}
|
|
200
|
+
:host([narrow][show='solid']) {
|
|
201
|
+
bottom: auto;
|
|
202
|
+
}
|
|
203
|
+
`]
|
|
204
|
+
|
|
205
|
+
/** The part and its evaluated shape as they stand when the editor opens. */
|
|
206
|
+
@property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
|
|
207
|
+
/** The figure's revision now; the editor remembers the one it opened at. */
|
|
208
|
+
@property({ type: Number }) revision = 0
|
|
209
|
+
/** Narrow: the outline or the solid behind. */
|
|
210
|
+
@property({ reflect: true }) show: 'outline' | 'solid' = 'outline'
|
|
211
|
+
/** Set when the space the editor stands in leaves too little beside it to see the solid. */
|
|
212
|
+
@property({ type: Boolean, reflect: true }) narrow = false
|
|
213
|
+
private room = new ResizeObserver(([entry]) => {
|
|
214
|
+
this.narrow = (entry?.contentRect.width ?? 0) < NARROW_BELOW
|
|
215
|
+
if (!this.narrow) this.show = 'outline'
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
@state() private working: FigureSectionPoint[] = []
|
|
219
|
+
@state() private selected: string | null = null
|
|
220
|
+
@state() private note = ''
|
|
221
|
+
private history: FigureSectionPoint[][] = []
|
|
222
|
+
private lastValid: FigureSectionPoint[] = []
|
|
223
|
+
private openedAt = 0
|
|
224
|
+
private dragging: string | null = null
|
|
225
|
+
/** The points as they were when a drag began: kept once the point moves, so a press alone is not a step back. */
|
|
226
|
+
private dragFrom: FigureSectionPoint[] | null = null
|
|
227
|
+
private extent = 200
|
|
228
|
+
|
|
229
|
+
private get axis(): Axis {
|
|
230
|
+
return (this.body?.shape.extrusion?.axis ?? 'y') as Axis
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
connectedCallback() {
|
|
234
|
+
super.connectedCallback()
|
|
235
|
+
const s = this.body?.shape.extrusion?.section
|
|
236
|
+
this.working = s?.kind === 'polygon' ? s.points.map(p => ({ ...p })) : []
|
|
237
|
+
this.lastValid = this.working
|
|
238
|
+
this.openedAt = this.revision
|
|
239
|
+
this.extent = extentOf(this.working, this.axis)
|
|
240
|
+
window.addEventListener('keydown', this.keys, true)
|
|
241
|
+
if (this.parentElement) this.room.observe(this.parentElement)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
disconnectedCallback() {
|
|
245
|
+
window.removeEventListener('keydown', this.keys, true)
|
|
246
|
+
this.room.disconnect()
|
|
247
|
+
super.disconnectedCallback()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/*
|
|
251
|
+
* Heard first (capture on the window), and a key the editor takes goes no further: the scene behind listens too, and
|
|
252
|
+
* would take Esc as "select nothing" and Delete as "delete the part".
|
|
253
|
+
*/
|
|
254
|
+
private keys = (e: KeyboardEvent) => {
|
|
255
|
+
const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
|
|
256
|
+
const take = () => (e.preventDefault(), e.stopImmediatePropagation())
|
|
257
|
+
if (e.key === 'Escape') {
|
|
258
|
+
take()
|
|
259
|
+
this.cancel()
|
|
260
|
+
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !inField && this.selected) {
|
|
261
|
+
/* In a field the key deletes a character; outside, the chosen point. */
|
|
262
|
+
take()
|
|
263
|
+
this.removePoint(this.selected)
|
|
264
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !inField) {
|
|
265
|
+
take()
|
|
266
|
+
this.back()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private get stale() {
|
|
271
|
+
return this.revision !== this.openedAt
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private change(next: FigureSectionPoint[]) {
|
|
275
|
+
this.history.push(this.working)
|
|
276
|
+
this.working = next
|
|
277
|
+
this.note = ''
|
|
278
|
+
if (!issuesOf(next, this.axis).length) this.lastValid = next
|
|
279
|
+
this.preview()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private back() {
|
|
283
|
+
const before = this.history.pop()
|
|
284
|
+
if (!before) return
|
|
285
|
+
this.working = before
|
|
286
|
+
if (!issuesOf(before, this.axis).length) this.lastValid = before
|
|
287
|
+
this.preview()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The solid behind shows the working points, or the last valid ones while the working ones are refused. */
|
|
291
|
+
private preview() {
|
|
292
|
+
this.dispatchEvent(
|
|
293
|
+
new CustomEvent('command-live', { detail: { action: this.action(this.lastValid) }, bubbles: true, composed: true })
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private action(points: Points): FigureAuthoringAction {
|
|
298
|
+
return { kind: 'set-section-points', part: this.body!.id, points: points.map(p => ({ ...p })) }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private removePoint(id: string) {
|
|
302
|
+
const next = removePoint(this.working, id)
|
|
303
|
+
if ('refused' in next) {
|
|
304
|
+
this.note = i18next.t('figure.text.section-issue-too-few')
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
this.selected = null
|
|
308
|
+
this.change(next)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private apply() {
|
|
312
|
+
if (this.stale || issuesOf(this.working, this.axis).length) return
|
|
313
|
+
this.dispatchEvent(new CustomEvent('command', { detail: { action: this.action(this.working) }, bubbles: true, composed: true }))
|
|
314
|
+
this.close()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private cancel() {
|
|
318
|
+
this.dispatchEvent(new CustomEvent('command-live', { detail: { action: null }, bubbles: true, composed: true }))
|
|
319
|
+
this.close()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private close() {
|
|
323
|
+
this.dispatchEvent(new CustomEvent('section-editor-close', { bubbles: true, composed: true }))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private svgPoint(e: PointerEvent): { h: number; v: number } {
|
|
327
|
+
const el = this.renderRoot.querySelector('svg')!
|
|
328
|
+
const pt = el.createSVGPoint()
|
|
329
|
+
pt.x = e.clientX
|
|
330
|
+
pt.y = e.clientY
|
|
331
|
+
const p = pt.matrixTransform(el.getScreenCTM()!.inverse())
|
|
332
|
+
return { h: Math.round(p.x), v: Math.round(p.y) }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
render() {
|
|
336
|
+
if (!this.body) return nothing
|
|
337
|
+
const issues = issuesOf(this.working, this.axis)
|
|
338
|
+
const length = this.body.shape.extrusion ? this.body.shape.extrusion.end - this.body.shape.extrusion.start : 0
|
|
339
|
+
return html`
|
|
340
|
+
<header>
|
|
341
|
+
<h2>${i18next.t('figure.label.section-editor')}</h2>
|
|
342
|
+
<span>${this.body.id} · ${i18next.t('figure.text.section-editor-length', { length: String(Math.round(length * 1000) / 1000) })}</span>
|
|
343
|
+
</header>
|
|
344
|
+
<nav tabs>
|
|
345
|
+
${(['outline', 'solid'] as const).map(
|
|
346
|
+
one => html`<button ?on=${this.show === one} @click=${() => (this.show = one)}>${i18next.t(`figure.label.section-editor-${one}`)}</button>`
|
|
347
|
+
)}
|
|
348
|
+
</nav>
|
|
349
|
+
<div body>
|
|
350
|
+
<div outline>${this.drawing(issues)}</div>
|
|
351
|
+
<aside>${this.list(issues)}</aside>
|
|
352
|
+
</div>
|
|
353
|
+
<footer>
|
|
354
|
+
<button ?disabled=${!this.history.length} @click=${() => this.back()}>${i18next.t('figure.button.section-editor-back')}</button>
|
|
355
|
+
<span grow ?stale=${this.stale}>
|
|
356
|
+
${this.stale ? i18next.t('figure.text.section-editor-stale') : i18next.t('figure.text.section-editor-hint')}
|
|
357
|
+
</span>
|
|
358
|
+
<button @click=${() => this.cancel()}>${i18next.t('figure.button.section-editor-cancel')}</button>
|
|
359
|
+
<button primary ?disabled=${this.stale || issues.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
|
|
360
|
+
</footer>
|
|
361
|
+
`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private drawing(issues: ReturnType<typeof issuesOf>) {
|
|
365
|
+
const [h, v] = SCREEN[this.axis]
|
|
366
|
+
const e = this.extent
|
|
367
|
+
const marked = markedBy(issues)
|
|
368
|
+
const pts = this.working
|
|
369
|
+
const sides = pts.length >= 2 ? figureSectionSidesOf({ kind: 'polygon', points: pts }, this.axis) : []
|
|
370
|
+
const at = (id: string) => pts.find(p => p.id === id)!
|
|
371
|
+
const grid = Math.max(10, Math.pow(10, Math.floor(Math.log10(e))) / 2)
|
|
372
|
+
const lines: number[] = []
|
|
373
|
+
for (let g = -Math.floor(e / grid) * grid; g <= e; g += grid) lines.push(g)
|
|
374
|
+
const invalid = issues.length > 0
|
|
375
|
+
return html`<div caption ?last-valid=${invalid}>
|
|
376
|
+
${invalid ? i18next.t('figure.text.section-editor-last-valid') : i18next.t('figure.text.section-editor-units', { h: h.toUpperCase(), v: v.toUpperCase() })}
|
|
377
|
+
</div>
|
|
378
|
+
<svg
|
|
379
|
+
viewBox=${`${-e} ${-e} ${2 * e} ${2 * e}`}
|
|
380
|
+
role="img"
|
|
381
|
+
aria-label=${i18next.t('figure.label.section-editor')}
|
|
382
|
+
@pointermove=${(ev: PointerEvent) => {
|
|
383
|
+
if (!this.dragging) return
|
|
384
|
+
if (this.dragFrom) this.history.push(this.dragFrom), (this.dragFrom = null)
|
|
385
|
+
const p = this.svgPoint(ev)
|
|
386
|
+
this.working = movePoint(this.working, this.dragging, { [h]: p.h, [v]: p.v })
|
|
387
|
+
if (!issuesOf(this.working, this.axis).length) this.lastValid = this.working
|
|
388
|
+
this.preview()
|
|
389
|
+
}}
|
|
390
|
+
@pointerup=${() => ((this.dragging = null), (this.dragFrom = null))}
|
|
391
|
+
@pointerleave=${() => ((this.dragging = null), (this.dragFrom = null))}
|
|
392
|
+
>
|
|
393
|
+
${svg`${lines.map(g => svg`<line x1=${g} y1=${-e} x2=${g} y2=${e} stroke="var(--md-sys-color-outline-variant)" stroke-width=${e / 400} opacity="0.5" />
|
|
394
|
+
<line x1=${-e} y1=${g} x2=${e} y2=${g} stroke="var(--md-sys-color-outline-variant)" stroke-width=${e / 400} opacity="0.5" />`)}
|
|
395
|
+
<line x1=${-e} y1="0" x2=${e} y2="0" stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
|
|
396
|
+
<line x1="0" y1=${-e} x2="0" y2=${e} stroke="var(--md-sys-color-outline)" stroke-width=${e / 250} />
|
|
397
|
+
<text x=${e * 0.92} y=${-e * 0.02} font-size=${e / 18} fill="var(--md-sys-color-on-surface-variant)">${h.toUpperCase()}</text>
|
|
398
|
+
<text x=${e * 0.02} y=${e * 0.95} font-size=${e / 18} fill="var(--md-sys-color-on-surface-variant)">${v.toUpperCase()}</text>
|
|
399
|
+
${invalid && this.lastValid.length
|
|
400
|
+
? svg`<polygon points=${this.lastValid.map(p => `${p[h]},${p[v]}`).join(' ')} fill="none" stroke="var(--md-sys-color-outline)" stroke-dasharray=${`${e / 50} ${e / 70}`} stroke-width=${e / 250} />`
|
|
401
|
+
: nothing}
|
|
402
|
+
<polygon points=${pts.map(p => `${p[h]},${p[v]}`).join(' ')} fill=${invalid ? 'none' : 'color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent)'} stroke="none" />
|
|
403
|
+
${sides.map(s => {
|
|
404
|
+
const a = at(s.from!),
|
|
405
|
+
b = at(s.to!)
|
|
406
|
+
const bad = marked.sides.has(s.id)
|
|
407
|
+
return svg`<line x1=${a[h]} y1=${a[v]} x2=${b[h]} y2=${b[v]}
|
|
408
|
+
stroke=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'} stroke-width=${e / (bad ? 90 : 130)}
|
|
409
|
+
stroke-dasharray=${bad ? `${e / 40} ${e / 80}` : ''} />
|
|
410
|
+
<line side=${s.id} x1=${a[h]} y1=${a[v]} x2=${b[h]} y2=${b[v]} stroke="transparent" stroke-width=${e / 25} style="cursor: copy"
|
|
411
|
+
@pointerdown=${(ev: PointerEvent) => {
|
|
412
|
+
ev.stopPropagation()
|
|
413
|
+
const made = insertAfter(this.working, s.from!, this.axis)
|
|
414
|
+
this.change(made.points)
|
|
415
|
+
this.selected = made.id
|
|
416
|
+
}}><title>${i18next.t('figure.text.section-editor-add-here', { a: pointLabel(s.from!), b: pointLabel(s.to!) })}</title></line>`
|
|
417
|
+
})}
|
|
418
|
+
${pts.map(p => {
|
|
419
|
+
const bad = marked.points.has(p.id),
|
|
420
|
+
on = this.selected === p.id
|
|
421
|
+
return svg`<g point=${p.id} style="cursor: grab"
|
|
422
|
+
@pointerdown=${(ev: PointerEvent) => {
|
|
423
|
+
ev.stopPropagation()
|
|
424
|
+
;(ev.target as Element).setPointerCapture?.(ev.pointerId)
|
|
425
|
+
this.dragFrom = this.working
|
|
426
|
+
this.selected = p.id
|
|
427
|
+
this.dragging = p.id
|
|
428
|
+
}}>
|
|
429
|
+
<circle cx=${p[h]} cy=${p[v]} r=${e / (on ? 28 : 36)} fill=${bad ? 'var(--md-sys-color-error)' : on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-surface)'}
|
|
430
|
+
stroke=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)'} stroke-width=${e / 250} />
|
|
431
|
+
<text x=${p[h]! + e / 22} y=${p[v]! - e / 22} font-size=${e / 16} font-weight="600"
|
|
432
|
+
fill=${bad ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-on-surface)'}>${pointLabel(p.id)}</text>
|
|
433
|
+
</g>`
|
|
434
|
+
})}`}
|
|
435
|
+
</svg>`
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private list(issues: ReturnType<typeof issuesOf>) {
|
|
439
|
+
const [h, v] = SCREEN[this.axis]
|
|
440
|
+
const marked = markedBy(issues)
|
|
441
|
+
return html`<ol points aria-label=${i18next.t('figure.label.section-editor-points')}>
|
|
442
|
+
${this.working.map(
|
|
443
|
+
p => html`<li ?selected=${this.selected === p.id} ?marked=${marked.points.has(p.id)} @focusin=${() => (this.selected = p.id)}>
|
|
444
|
+
<b>${pointLabel(p.id)}</b>
|
|
445
|
+
${([h, v] as const).map(
|
|
446
|
+
axis => html`<input
|
|
447
|
+
type="number"
|
|
448
|
+
step="any"
|
|
449
|
+
inputmode="decimal"
|
|
450
|
+
aria-label=${i18next.t('figure.label.section-editor-coordinate', { point: pointLabel(p.id), axis: axis.toUpperCase() })}
|
|
451
|
+
.value=${live(String(p[axis] ?? ''))}
|
|
452
|
+
@change=${(ev: Event) => {
|
|
453
|
+
const input = ev.target as HTMLInputElement
|
|
454
|
+
const value = Number(input.value)
|
|
455
|
+
if (input.value === '' || !Number.isFinite(value)) {
|
|
456
|
+
input.value = String(p[axis] ?? '')
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
this.change(movePoint(this.working, p.id, { [axis]: value }))
|
|
460
|
+
}}
|
|
461
|
+
/>`
|
|
462
|
+
)}
|
|
463
|
+
<button
|
|
464
|
+
?disabled=${this.working.length <= 3}
|
|
465
|
+
title=${this.working.length <= 3 ? i18next.t('figure.text.section-issue-too-few') : ''}
|
|
466
|
+
aria-label=${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
|
|
467
|
+
@click=${() => this.removePoint(p.id)}
|
|
468
|
+
>
|
|
469
|
+
${i18next.t('figure.button.section-editor-remove', { point: pointLabel(p.id) })}
|
|
470
|
+
</button>
|
|
471
|
+
</li>`
|
|
472
|
+
)}
|
|
473
|
+
</ol>
|
|
474
|
+
${this.note ? html`<small role="status">${this.note}</small>` : nothing}
|
|
475
|
+
${issues.length
|
|
476
|
+
? html`<ul issues role="alert">
|
|
477
|
+
${issues.map(i => {
|
|
478
|
+
const w = issueWords(i, this.working, this.axis)
|
|
479
|
+
return html`<li>${i18next.t(w.key, w.params)}</li>`
|
|
480
|
+
})}
|
|
481
|
+
</ul>`
|
|
482
|
+
: nothing}`
|
|
483
|
+
}
|
|
484
|
+
}
|