@things-factory/figure-ui 10.1.125 → 10.1.127
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/align.ts +6 -4
- package/client/modeller/canvas.ts +6 -1
- package/client/modeller/editor.ts +149 -34
- package/client/modeller/evaluated.ts +47 -0
- package/client/modeller/figure-facts.ts +8 -0
- package/client/modeller/figure-preview.ts +38 -16
- package/client/modeller/figure-properties.ts +32 -0
- package/client/modeller/figure-verdict.ts +10 -1
- package/client/modeller/joint-fan.ts +4 -7
- package/client/modeller/links.ts +2 -1
- package/dist-client/modeller/align.d.ts +2 -1
- package/dist-client/modeller/align.js +6 -4
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/canvas.js +8 -1
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +35 -1
- package/dist-client/modeller/editor.js +138 -37
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/evaluated.d.ts +12 -0
- package/dist-client/modeller/evaluated.js +42 -0
- package/dist-client/modeller/evaluated.js.map +1 -0
- package/dist-client/modeller/figure-facts.d.ts +0 -7
- package/dist-client/modeller/figure-facts.js +9 -0
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-preview.js +38 -16
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +8 -2
- package/dist-client/modeller/figure-properties.js +26 -0
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-verdict.d.ts +2 -0
- package/dist-client/modeller/figure-verdict.js +10 -2
- package/dist-client/modeller/figure-verdict.js.map +1 -1
- package/dist-client/modeller/joint-fan.js +4 -7
- package/dist-client/modeller/joint-fan.js.map +1 -1
- package/dist-client/modeller/links.js +3 -2
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +68 -112
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/evaluated.test.ts +40 -0
- package/test/gizmo.test.ts +1 -1
- package/test/mode-switch.test.ts +41 -0
package/client/modeller/align.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
|
|
2
|
+
import { evaluatedGeometryOf } from './evaluated.js'
|
|
2
3
|
import type { FigureAsset, FigureInputChange, FigureWorldBox } from '@hatiolab/figure-model'
|
|
3
4
|
|
|
4
5
|
import type { GizmoReach } from './gizmo.js'
|
|
@@ -19,14 +20,15 @@ export type AlignTool = 'centre-x' | 'centre-z' | 'snap'
|
|
|
19
20
|
|
|
20
21
|
const AXES = ['x', 'y', 'z'] as const
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
/** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
|
|
24
|
+
export function boxOf(asset: FigureAsset, part: string, values?: Record<string, number>): FigureWorldBox | undefined {
|
|
25
|
+
const geometry = evaluatedGeometryOf(asset, values)
|
|
24
26
|
return figureOccupiedBoxesOf(geometry, figureWritersOf(asset)).find(box => box.part === part)
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
|
|
28
30
|
export function gridStepOf(asset: FigureAsset): number {
|
|
29
|
-
const geometry =
|
|
31
|
+
const geometry = evaluatedGeometryOf(asset)
|
|
30
32
|
const boxes = figureOccupiedBoxesOf(geometry, figureWritersOf(asset))
|
|
31
33
|
if (!boxes.length) return 1
|
|
32
34
|
const span = Math.max(
|
|
@@ -115,7 +115,10 @@ export class FigureCanvas extends LitElement {
|
|
|
115
115
|
|
|
116
116
|
firstUpdated() {
|
|
117
117
|
this.standUp()
|
|
118
|
-
|
|
118
|
+
/* Out of sight it has no size to draw at: it resizes when it is shown again, not to nothing. */
|
|
119
|
+
this.watcher = new ResizeObserver(() => {
|
|
120
|
+
if (this.checkVisibility?.() !== false) this.scene?.resize?.()
|
|
121
|
+
})
|
|
119
122
|
this.watcher.observe(this)
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -140,6 +143,8 @@ export class FigureCanvas extends LitElement {
|
|
|
140
143
|
* faked (user, 2026-09-24: no card had a picture after this canvas moved onto the scene).
|
|
141
144
|
*/
|
|
142
145
|
async snapshot(width = 480, height = 360): Promise<string | undefined> {
|
|
146
|
+
/* Kept standing while the preview is in front, it is not drawn: no picture, as when there was no canvas. */
|
|
147
|
+
if (this.checkVisibility?.() === false) return undefined
|
|
143
148
|
const layer = this.scene?.root as (SceneLayer & { snapshot3d?: (w?: number, h?: number, type?: string, quality?: number) => Promise<string | undefined> }) | undefined
|
|
144
149
|
if (!layer?.snapshot3d) return undefined
|
|
145
150
|
try {
|
|
@@ -6,7 +6,7 @@ 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, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, 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, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf, seedFigureEvaluation, upgradeFigurePlacement } from '@hatiolab/figure-model'
|
|
10
10
|
import type { RecipeTemplate } from './recipes.js'
|
|
11
11
|
import './figure-recipe-catalog.js'
|
|
12
12
|
import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
|
|
@@ -48,6 +48,7 @@ import type { FigureRenderPart } from './renderer.js'
|
|
|
48
48
|
import type { FigureBudgetLine } from './maturity.js'
|
|
49
49
|
import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
50
50
|
import { jointFanOf } from './joint-fan.js'
|
|
51
|
+
import { seedEvaluatedGeometry } from './evaluated.js'
|
|
51
52
|
import { jointDragOf } from './joint-drag.js'
|
|
52
53
|
import type { FirstSave } from './first-save.js'
|
|
53
54
|
import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
|
|
@@ -72,6 +73,20 @@ function foldSaid(): boolean {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Product editor surface. The injected session is shared by page persistence and AI commands. */
|
|
76
|
+
/** The words a panel is given when there are none: one array, so it does not read as a change. */
|
|
77
|
+
const NO_WORDS: string[] = []
|
|
78
|
+
|
|
79
|
+
/** How long a command's candidate rests before its preview is worked out. */
|
|
80
|
+
const PREVIEW_SETTLE_MS = 150
|
|
81
|
+
|
|
82
|
+
function deepFreeze<T>(value: T): T {
|
|
83
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
84
|
+
Object.freeze(value)
|
|
85
|
+
for (const inner of Object.values(value)) deepFreeze(inner)
|
|
86
|
+
}
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
@customElement('figure-editor')
|
|
76
91
|
export class FigureEditor extends LitElement {
|
|
77
92
|
/* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
|
|
@@ -144,6 +159,10 @@ export class FigureEditor extends LitElement {
|
|
|
144
159
|
min-width: 0;
|
|
145
160
|
min-height: 0;
|
|
146
161
|
}
|
|
162
|
+
/* The edit canvas stays standing while the preview is in front (a 3D scene costs a third of a second to make), out of sight. */
|
|
163
|
+
div[middle][hidden] {
|
|
164
|
+
display: none;
|
|
165
|
+
}
|
|
147
166
|
ox-canvas-bar .status {
|
|
148
167
|
padding: 0 8px;
|
|
149
168
|
color: var(--md-sys-color-on-surface-variant);
|
|
@@ -517,6 +536,8 @@ export class FigureEditor extends LitElement {
|
|
|
517
536
|
disconnectedCallback() {
|
|
518
537
|
window.removeEventListener('keydown', this.onKey, true)
|
|
519
538
|
this.widthWatch.disconnect()
|
|
539
|
+
clearTimeout(this.previewTimer)
|
|
540
|
+
clearTimeout(this.detachTimer)
|
|
520
541
|
super.disconnectedCallback()
|
|
521
542
|
}
|
|
522
543
|
/*
|
|
@@ -837,7 +858,7 @@ export class FigureEditor extends LitElement {
|
|
|
837
858
|
*/
|
|
838
859
|
private insertRecipe(recipe: FigureRecipe, values: Record<string, FigureRecipeValue>) {
|
|
839
860
|
try {
|
|
840
|
-
const asset =
|
|
861
|
+
const asset = this.plainAsset() as FigureAsset
|
|
841
862
|
const checked = parseFigureRecipe(recipe)
|
|
842
863
|
const id = figureRecipeIdFor(asset, checked)
|
|
843
864
|
const steps = resolveFigureRecipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
|
|
@@ -853,7 +874,7 @@ export class FigureEditor extends LitElement {
|
|
|
853
874
|
/** The design values of the asset being edited. */
|
|
854
875
|
private designValues(): Record<string, number> {
|
|
855
876
|
try {
|
|
856
|
-
return this.session ? (
|
|
877
|
+
return this.session ? (this.plainAsset().designInputs ?? {}) : {}
|
|
857
878
|
} catch {
|
|
858
879
|
return {}
|
|
859
880
|
}
|
|
@@ -864,7 +885,7 @@ export class FigureEditor extends LitElement {
|
|
|
864
885
|
/* A repeat is moved and sized through its row, not by a gizmo on one copy. */
|
|
865
886
|
if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
|
|
866
887
|
try {
|
|
867
|
-
const asset =
|
|
888
|
+
const asset = this.plainAsset()
|
|
868
889
|
const reach = gizmoReachOf(asset, id)
|
|
869
890
|
/*
|
|
870
891
|
A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
|
|
@@ -889,7 +910,7 @@ export class FigureEditor extends LitElement {
|
|
|
889
910
|
private transformed(id: string, result: GizmoResult) {
|
|
890
911
|
const view = this.session!.read()
|
|
891
912
|
/* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
|
|
892
|
-
const asset =
|
|
913
|
+
const asset = this.plainAsset() as FigureAsset
|
|
893
914
|
const drawn = view.geometry.find(g => g.id === id) as unknown as FigureRenderPart | undefined
|
|
894
915
|
const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
|
|
895
916
|
if (drag) {
|
|
@@ -966,7 +987,7 @@ export class FigureEditor extends LitElement {
|
|
|
966
987
|
/** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
|
|
967
988
|
private align(tool: AlignTool) {
|
|
968
989
|
this.run(() => {
|
|
969
|
-
const asset =
|
|
990
|
+
const asset = this.plainAsset()
|
|
970
991
|
const changes = alignChanges(asset, this.selected, this.reachOf(this.selected), tool)
|
|
971
992
|
if (changes.length) this.session!.apply(changes)
|
|
972
993
|
})
|
|
@@ -979,7 +1000,7 @@ export class FigureEditor extends LitElement {
|
|
|
979
1000
|
this.session!.edit({ kind: 'duplicate-part', target: id })
|
|
980
1001
|
const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
|
|
981
1002
|
if (!copy) return
|
|
982
|
-
this.session!.apply(mirrorChanges(
|
|
1003
|
+
this.session!.apply(mirrorChanges(this.plainAsset(), id, copy, axis))
|
|
983
1004
|
this.selected = copy
|
|
984
1005
|
})
|
|
985
1006
|
}
|
|
@@ -1017,7 +1038,7 @@ export class FigureEditor extends LitElement {
|
|
|
1017
1038
|
@click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
|
|
1018
1039
|
}
|
|
1019
1040
|
private alignToPlane() {
|
|
1020
|
-
const asset =
|
|
1041
|
+
const asset = this.parsedAsset()
|
|
1021
1042
|
const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
|
|
1022
1043
|
if (refusal) {
|
|
1023
1044
|
this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
|
|
@@ -1031,7 +1052,7 @@ export class FigureEditor extends LitElement {
|
|
|
1031
1052
|
if (!this.selected || !reach.translate) return null
|
|
1032
1053
|
let asset: unknown
|
|
1033
1054
|
try {
|
|
1034
|
-
asset =
|
|
1055
|
+
asset = this.plainAsset()
|
|
1035
1056
|
} catch {
|
|
1036
1057
|
return null
|
|
1037
1058
|
}
|
|
@@ -1099,19 +1120,41 @@ export class FigureEditor extends LitElement {
|
|
|
1099
1120
|
}
|
|
1100
1121
|
}
|
|
1101
1122
|
/** Size response, fastening and motion for the selected part, as authoring commands. */
|
|
1102
|
-
|
|
1103
|
-
|
|
1123
|
+
/*
|
|
1124
|
+
What a panel is handed is the same object while it says the same thing: a new array each render reads as a change to
|
|
1125
|
+
the panel, which draws itself again for nothing (sixteen times for one edit).
|
|
1126
|
+
*/
|
|
1127
|
+
private dimsSaid?: { key: string; dims: [number, number, number] | undefined }
|
|
1128
|
+
private dimsOf(asset: FigureAsset): [number, number, number] | undefined {
|
|
1129
|
+
const view = this.session?.read()
|
|
1130
|
+
const key = `${view?.context}|${view?.revision}|${this.selected}`
|
|
1131
|
+
if (this.dimsSaid?.key === key) return this.dimsSaid.dims
|
|
1132
|
+
let dims: [number, number, number] | undefined
|
|
1104
1133
|
try {
|
|
1105
|
-
|
|
1134
|
+
dims = boxOf(asset, this.selected, this.currentInputs())?.dims
|
|
1106
1135
|
} catch {
|
|
1107
|
-
|
|
1136
|
+
dims = undefined
|
|
1108
1137
|
}
|
|
1109
|
-
|
|
1138
|
+
this.dimsSaid = { key, dims }
|
|
1139
|
+
return dims
|
|
1140
|
+
}
|
|
1141
|
+
private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
|
|
1142
|
+
private previewWordsNow(): string[] {
|
|
1143
|
+
const preview = this.currentPreview()
|
|
1144
|
+
const language = i18next.language
|
|
1145
|
+
if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language) return this.previewWordsSaid.words
|
|
1146
|
+
const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params))
|
|
1147
|
+
this.previewWordsSaid = { preview, part: this.selected, language, words }
|
|
1148
|
+
return words
|
|
1149
|
+
}
|
|
1150
|
+
private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
|
|
1151
|
+
let asset
|
|
1110
1152
|
try {
|
|
1111
|
-
|
|
1153
|
+
asset = this.plainAsset()
|
|
1112
1154
|
} catch {
|
|
1113
|
-
|
|
1155
|
+
return null
|
|
1114
1156
|
}
|
|
1157
|
+
const dims = this.dimsOf(asset)
|
|
1115
1158
|
return html`<figure-links
|
|
1116
1159
|
.asset=${asset}
|
|
1117
1160
|
.section=${section}
|
|
@@ -1119,10 +1162,10 @@ export class FigureEditor extends LitElement {
|
|
|
1119
1162
|
.dims=${dims}
|
|
1120
1163
|
?locked=${unavailable}
|
|
1121
1164
|
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
1122
|
-
.previewWords=${
|
|
1165
|
+
.previewWords=${this.previewWordsNow()}
|
|
1123
1166
|
.detachWords=${this.detachWords(asset as FigureAsset | undefined)}
|
|
1124
|
-
.overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) :
|
|
1125
|
-
.overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) :
|
|
1167
|
+
.overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : NO_WORDS}
|
|
1168
|
+
.overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : NO_WORDS}
|
|
1126
1169
|
.motionEnds=${section === 'motion' ? this.motionEnds() : null}
|
|
1127
1170
|
@go-fasten=${() => this.goFasten()}
|
|
1128
1171
|
@preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
|
|
@@ -1151,7 +1194,7 @@ export class FigureEditor extends LitElement {
|
|
|
1151
1194
|
private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
|
|
1152
1195
|
let asset
|
|
1153
1196
|
try {
|
|
1154
|
-
asset =
|
|
1197
|
+
asset = this.plainAsset()
|
|
1155
1198
|
} catch {
|
|
1156
1199
|
return null
|
|
1157
1200
|
}
|
|
@@ -1236,7 +1279,7 @@ export class FigureEditor extends LitElement {
|
|
|
1236
1279
|
/* A size typed in keeps the face the figure's placement keeps -- figure-model's one rule, as the gizmo and "follow the size" do. */
|
|
1237
1280
|
const part = this.selected
|
|
1238
1281
|
const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
|
|
1239
|
-
this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(
|
|
1282
|
+
this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.parsedAsset(), part, [change]) : [change]))
|
|
1240
1283
|
// Rejected edits must also restore the displayed numeric value.
|
|
1241
1284
|
input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
|
|
1242
1285
|
}
|
|
@@ -1570,7 +1613,7 @@ export class FigureEditor extends LitElement {
|
|
|
1570
1613
|
const pitch = input.valueAsNumber
|
|
1571
1614
|
if (!Number.isFinite(pitch) || pitch === field.value) return
|
|
1572
1615
|
/* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
|
|
1573
|
-
const from = this.pitchInputOf(
|
|
1616
|
+
const from = this.pitchInputOf(this.plainAsset() as FigureAsset, this.selected)
|
|
1574
1617
|
if (from) {
|
|
1575
1618
|
this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
|
|
1576
1619
|
input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
|
|
@@ -1610,19 +1653,33 @@ export class FigureEditor extends LitElement {
|
|
|
1610
1653
|
await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
|
|
1611
1654
|
}
|
|
1612
1655
|
/** The fastening form's command, seen before it is done; null clears what was shown. */
|
|
1656
|
+
/**
|
|
1657
|
+
* What a command would do is worked out on a copy of the figure -- several compiles of it -- so it waits for the field to
|
|
1658
|
+
* rest: a number typed digit by digit asks once, for the number it ends on. Clearing is at once, and the same question on
|
|
1659
|
+
* the same version is not asked again.
|
|
1660
|
+
*/
|
|
1661
|
+
private previewTimer?: ReturnType<typeof setTimeout>
|
|
1613
1662
|
private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
|
|
1663
|
+
clearTimeout(this.previewTimer)
|
|
1614
1664
|
if (!candidate || !this.session || !this.selected) {
|
|
1615
1665
|
this.preview = undefined
|
|
1616
1666
|
return
|
|
1617
1667
|
}
|
|
1618
1668
|
const view = this.session.read()
|
|
1669
|
+
const same = this.preview && this.preview.key === JSON.stringify(candidate) && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision
|
|
1670
|
+
if (same) return
|
|
1671
|
+
this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
|
|
1672
|
+
}
|
|
1673
|
+
private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
|
|
1674
|
+
if (!this.session || !this.selected) return
|
|
1675
|
+
const view = this.session.read()
|
|
1619
1676
|
const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
|
|
1620
1677
|
try {
|
|
1621
1678
|
const seen = this.session.previewCommand(candidate.action)
|
|
1622
1679
|
let face: FigureFaceArea | undefined
|
|
1623
1680
|
if (candidate.face) {
|
|
1624
1681
|
try {
|
|
1625
|
-
face = figurePartFaceOf(
|
|
1682
|
+
face = figurePartFaceOf(this.parsedAsset(), candidate.face.part, candidate.face.face, this.currentInputs())
|
|
1626
1683
|
} catch {
|
|
1627
1684
|
face = undefined
|
|
1628
1685
|
}
|
|
@@ -1660,16 +1717,35 @@ export class FigureEditor extends LitElement {
|
|
|
1660
1717
|
when the copy shows it does.
|
|
1661
1718
|
*/
|
|
1662
1719
|
private detachSaid?: { key: string; words: string[] }
|
|
1720
|
+
private detachTimer?: ReturnType<typeof setTimeout>
|
|
1721
|
+
private detachAsked?: string
|
|
1722
|
+
/**
|
|
1723
|
+
* Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
|
|
1724
|
+
* edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
|
|
1725
|
+
*/
|
|
1663
1726
|
private detachWords(asset: FigureAsset | undefined): string[] {
|
|
1664
1727
|
const view = this.session?.read()
|
|
1665
1728
|
const joint = asset?.joints?.find(j => j.body1.part === this.selected)
|
|
1666
|
-
if (!asset || !view || !joint || !this.session) return
|
|
1729
|
+
if (!asset || !view || !joint || !this.session) return NO_WORDS
|
|
1667
1730
|
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1668
1731
|
if (this.detachSaid?.key === key) return this.detachSaid.words
|
|
1732
|
+
if (this.detachAsked !== key) {
|
|
1733
|
+
this.detachAsked = key
|
|
1734
|
+
clearTimeout(this.detachTimer)
|
|
1735
|
+
this.detachTimer = setTimeout(() => {
|
|
1736
|
+
const now = this.session?.read()
|
|
1737
|
+
if (!now || `${now.context}|${now.revision}|${this.selected}` !== key) return
|
|
1738
|
+
this.computeDetachWords(asset, key)
|
|
1739
|
+
this.requestUpdate()
|
|
1740
|
+
}, PREVIEW_SETTLE_MS)
|
|
1741
|
+
}
|
|
1742
|
+
return NO_WORDS
|
|
1743
|
+
}
|
|
1744
|
+
private computeDetachWords(asset: FigureAsset, key: string): string[] {
|
|
1669
1745
|
const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
|
|
1670
1746
|
let words: string[]
|
|
1671
1747
|
try {
|
|
1672
|
-
const seen = this.session
|
|
1748
|
+
const seen = this.session!.previewCommand({ kind: 'detach', part: this.selected })
|
|
1673
1749
|
const own = seen.moves.some(m => m.body === this.selected)
|
|
1674
1750
|
words = own
|
|
1675
1751
|
? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
|
|
@@ -1695,7 +1771,7 @@ export class FigureEditor extends LitElement {
|
|
|
1695
1771
|
}
|
|
1696
1772
|
private overhangWords(asset: FigureAsset | undefined): string[] {
|
|
1697
1773
|
const view = this.session?.read()
|
|
1698
|
-
if (!asset || !view || !this.selected) return
|
|
1774
|
+
if (!asset || !view || !this.selected) return NO_WORDS
|
|
1699
1775
|
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1700
1776
|
if (this.overhangSaid?.key === key) return this.overhangSaid.words
|
|
1701
1777
|
const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
|
|
@@ -1731,7 +1807,7 @@ export class FigureEditor extends LitElement {
|
|
|
1731
1807
|
if (this.motionEndsSaid?.key !== key) {
|
|
1732
1808
|
let ends: MotionEnds | null = null
|
|
1733
1809
|
try {
|
|
1734
|
-
ends = motionEndsOf(
|
|
1810
|
+
ends = motionEndsOf(this.parsedAsset(), this.selected, this.currentInputs())
|
|
1735
1811
|
} catch {
|
|
1736
1812
|
ends = null
|
|
1737
1813
|
}
|
|
@@ -1770,9 +1846,47 @@ export class FigureEditor extends LitElement {
|
|
|
1770
1846
|
this.faceMapping = undefined
|
|
1771
1847
|
if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
|
|
1772
1848
|
}
|
|
1849
|
+
/**
|
|
1850
|
+
* The figure as the session holds it now, parsed once per text: a render asks for it a dozen times, and parsing the whole
|
|
1851
|
+
* figure is most of what a selection costs. Read, never changed -- what changes a figure goes through the session.
|
|
1852
|
+
*/
|
|
1853
|
+
/**
|
|
1854
|
+
* The session has evaluated this figure at this version, with these inputs: the screen's questions of the text it just
|
|
1855
|
+
* parsed are answered from that, not by evaluating the figure again (each is tens of milliseconds on a real figure).
|
|
1856
|
+
*/
|
|
1857
|
+
private seeded(asset: FigureAsset): FigureAsset {
|
|
1858
|
+
const view = this.session?.read()
|
|
1859
|
+
if (view?.geometry.length) {
|
|
1860
|
+
const values = this.currentInputs()
|
|
1861
|
+
seedEvaluatedGeometry(asset, values, view.geometry as never)
|
|
1862
|
+
seedFigureEvaluation(asset, values, view.geometry)
|
|
1863
|
+
}
|
|
1864
|
+
return asset
|
|
1865
|
+
}
|
|
1866
|
+
private parsed?: { text: string; asset: FigureAsset }
|
|
1867
|
+
private parsedAsset(): FigureAsset {
|
|
1868
|
+
const text = this.session!.source()
|
|
1869
|
+
if (this.parsed?.text !== text) {
|
|
1870
|
+
/* The session's text is what its workspace exported, compiled then: it is read here, not compiled again. */
|
|
1871
|
+
const plain = this.plainAsset()
|
|
1872
|
+
const upgraded = upgradeFigurePlacement(plain)
|
|
1873
|
+
this.parsed = { text, asset: upgraded === plain ? plain : this.seeded(deepFreeze(upgraded)) }
|
|
1874
|
+
}
|
|
1875
|
+
return this.parsed.asset
|
|
1876
|
+
}
|
|
1877
|
+
private plain?: { text: string; asset: FigureAsset }
|
|
1878
|
+
/**
|
|
1879
|
+
* The figure as plain data, parsed once per text and frozen: the render, the gizmo's reach and every tool read it, and
|
|
1880
|
+
* a copy for each of them cost more than the selection they serve. A change goes through the session, never through this.
|
|
1881
|
+
*/
|
|
1882
|
+
private plainAsset(): FigureAsset {
|
|
1883
|
+
const text = this.session!.source()
|
|
1884
|
+
if (this.plain?.text !== text) this.plain = { text, asset: this.seeded(deepFreeze(JSON.parse(text))) }
|
|
1885
|
+
return this.plain.asset
|
|
1886
|
+
}
|
|
1773
1887
|
private asset(): unknown {
|
|
1774
1888
|
try {
|
|
1775
|
-
return
|
|
1889
|
+
return this.plainAsset()
|
|
1776
1890
|
} catch {
|
|
1777
1891
|
return undefined
|
|
1778
1892
|
}
|
|
@@ -1907,7 +2021,7 @@ export class FigureEditor extends LitElement {
|
|
|
1907
2021
|
let summary: { kind: string; size: string } | undefined
|
|
1908
2022
|
try {
|
|
1909
2023
|
summary = this.selected
|
|
1910
|
-
? partSummaryOf(
|
|
2024
|
+
? partSummaryOf(this.parsedAsset(), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
|
|
1911
2025
|
: undefined
|
|
1912
2026
|
} catch {
|
|
1913
2027
|
summary = undefined
|
|
@@ -1944,7 +2058,7 @@ export class FigureEditor extends LitElement {
|
|
|
1944
2058
|
*/
|
|
1945
2059
|
let roomOnly: string[] = []
|
|
1946
2060
|
try {
|
|
1947
|
-
roomOnly = this.selected ? figureRoomOnlyInputsOf(
|
|
2061
|
+
roomOnly = this.selected ? figureRoomOnlyInputsOf(this.parsedAsset(), this.selected) : []
|
|
1948
2062
|
} catch {
|
|
1949
2063
|
roomOnly = []
|
|
1950
2064
|
}
|
|
@@ -1954,7 +2068,7 @@ export class FigureEditor extends LitElement {
|
|
|
1954
2068
|
try {
|
|
1955
2069
|
const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
|
|
1956
2070
|
const placed = body ? placementOf(body) : undefined
|
|
1957
|
-
const parsed = this.selected ?
|
|
2071
|
+
const parsed = this.selected ? this.parsedAsset() : undefined
|
|
1958
2072
|
if (placed && parsed)
|
|
1959
2073
|
held = {
|
|
1960
2074
|
position: placed.position,
|
|
@@ -2050,7 +2164,8 @@ export class FigureEditor extends LitElement {
|
|
|
2050
2164
|
}}
|
|
2051
2165
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
2052
2166
|
></figure-preview>`
|
|
2053
|
-
:
|
|
2167
|
+
: nothing}
|
|
2168
|
+
<div middle ?hidden=${this.middle === 'preview'}>
|
|
2054
2169
|
${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
|
|
2055
2170
|
${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
|
|
2056
2171
|
<figure-canvas
|
|
@@ -2069,7 +2184,7 @@ export class FigureEditor extends LitElement {
|
|
|
2069
2184
|
@render-error=${(e: CustomEvent<Error>) => {
|
|
2070
2185
|
this.error = e.detail.message
|
|
2071
2186
|
}}
|
|
2072
|
-
></figure-canvas></div
|
|
2187
|
+
></figure-canvas></div>
|
|
2073
2188
|
<aside slot="end">
|
|
2074
2189
|
<div tabs>
|
|
2075
2190
|
${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
|
|
@@ -2257,7 +2372,7 @@ export class FigureEditor extends LitElement {
|
|
|
2257
2372
|
private async askRoomRefit() {
|
|
2258
2373
|
let refit
|
|
2259
2374
|
try {
|
|
2260
|
-
refit = figureRoomRefitOf(
|
|
2375
|
+
refit = figureRoomRefitOf(this.parsedAsset())
|
|
2261
2376
|
} catch (error) {
|
|
2262
2377
|
this.error = this.said(error)
|
|
2263
2378
|
return
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
type Geometry = ReturnType<ReturnType<typeof compileFigureAsset>['evaluate']>['geometry']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A figure evaluated, held by the asset object it was asked of and the values it was asked under. One selection asks
|
|
8
|
+
* several questions of the same figure as it stands (a part's box, the grid, where a fastening keeps it, the fan of a
|
|
9
|
+
* joint), and each of them evaluated the whole figure again. Another asset object, or other values, is asked afresh.
|
|
10
|
+
* The geometry is read, never changed.
|
|
11
|
+
*/
|
|
12
|
+
const held = new WeakMap<object, Map<string, Geometry>>()
|
|
13
|
+
|
|
14
|
+
/** The values in hand that belong to a record's keys (design inputs, or state defaults). */
|
|
15
|
+
const pick = (values: Record<string, number>, of: Record<string, number>) => Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
|
|
16
|
+
|
|
17
|
+
/** The values the asset reads, in one order: the same inputs are one question however they were listed. */
|
|
18
|
+
const keyOf = (own: Record<string, number>, state: Record<string, number>) =>
|
|
19
|
+
JSON.stringify([Object.entries(own).sort(([a], [b]) => (a < b ? -1 : 1)), Object.entries(state).sort(([a], [b]) => (a < b ? -1 : 1))])
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tells the questions asked of this asset the answer already in hand -- the geometry the session evaluated for this
|
|
23
|
+
* figure under these values -- so the screen does not evaluate what the workspace has just evaluated. The caller says it
|
|
24
|
+
* is the same figure: the asset is the session's text at that version, the values its inputs. Nothing is checked here.
|
|
25
|
+
*/
|
|
26
|
+
export function seedEvaluatedGeometry(asset: FigureAsset, values: Record<string, number>, geometry: Geometry): void {
|
|
27
|
+
let mine = held.get(asset)
|
|
28
|
+
if (!mine) held.set(asset, (mine = new Map()))
|
|
29
|
+
mine.set(keyOf(pick(values, asset.designInputs), pick(values, asset.stateDefaults)), geometry)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `values` left out evaluates the asset as it is; given, they replace the design inputs and state defaults they name. */
|
|
33
|
+
export function evaluatedGeometryOf(asset: FigureAsset, values?: Record<string, number>): Geometry {
|
|
34
|
+
const own = values ? pick(values, asset.designInputs) : undefined
|
|
35
|
+
const state = values ? pick(values, asset.stateDefaults) : undefined
|
|
36
|
+
const key = values ? keyOf(own!, state!) : ''
|
|
37
|
+
let mine = held.get(asset)
|
|
38
|
+
if (!mine) held.set(asset, (mine = new Map()))
|
|
39
|
+
let geometry = mine.get(key)
|
|
40
|
+
if (!geometry) {
|
|
41
|
+
if (mine.size >= 8) mine.clear()
|
|
42
|
+
const source = values ? { ...asset, designInputs: { ...asset.designInputs, ...own }, stateDefaults: { ...asset.stateDefaults, ...state } } : asset
|
|
43
|
+
geometry = compileFigureAsset(source).evaluate().geometry
|
|
44
|
+
mine.set(key, geometry)
|
|
45
|
+
}
|
|
46
|
+
return geometry
|
|
47
|
+
}
|
|
@@ -29,7 +29,15 @@ export interface FigureFigureFacts {
|
|
|
29
29
|
* records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
|
|
30
30
|
* bodies (`place@1`), in the order the model lists them.
|
|
31
31
|
*/
|
|
32
|
+
let lastFacts: { source: string; facts: FigureFigureFacts } | undefined
|
|
32
33
|
export function figureFigureFacts(source: string): FigureFigureFacts {
|
|
34
|
+
/* The panel renders more often than the figure changes; the same text says the same facts. */
|
|
35
|
+
if (lastFacts?.source === source) return lastFacts.facts
|
|
36
|
+
const facts = figureFactsOf(source)
|
|
37
|
+
lastFacts = { source, facts }
|
|
38
|
+
return facts
|
|
39
|
+
}
|
|
40
|
+
function figureFactsOf(source: string): FigureFigureFacts {
|
|
33
41
|
const asset = parseFigureAsset(source) as FigureAsset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
|
|
34
42
|
const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
|
|
35
43
|
const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
|
|
@@ -27,6 +27,8 @@ interface SceneHandle {
|
|
|
27
27
|
dispose?: () => void
|
|
28
28
|
resize?: () => void
|
|
29
29
|
fit?: (type?: string) => void
|
|
30
|
+
/** The scene's container: it can make the content again while keeping the 3D renderer and the camera (undo and redo use it). */
|
|
31
|
+
rootContainer?: { rebuildContentModel?: (model: unknown, options?: { preserveThreeD?: boolean }) => void }
|
|
30
32
|
root?: {
|
|
31
33
|
set?: (patch: Record<string, unknown>) => void
|
|
32
34
|
fitCameraToModel?: () => void
|
|
@@ -345,30 +347,50 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
345
347
|
this.teardown()
|
|
346
348
|
return
|
|
347
349
|
}
|
|
348
|
-
this.teardown()
|
|
349
|
-
this.driving = {}
|
|
350
350
|
try {
|
|
351
351
|
/* Registered as a board registers it, under a type of this preview's own: a real type's drawings stay as they are. */
|
|
352
352
|
const type = `figure-preview-${this.context || 'new'}`
|
|
353
|
+
const size = authoredSizeOf(asset)
|
|
354
|
+
/*
|
|
355
|
+
The figure changed under a scene that is standing: the same type is registered again and the content made again
|
|
356
|
+
inside the same 3D scene. Making a scene anew costs about a third of a second and its first drawing compiles the
|
|
357
|
+
shaders again -- a value typed beside the preview held the screen for seconds. The scene is made anew only when
|
|
358
|
+
there is none, for another figure, or when the content cannot be made again in place.
|
|
359
|
+
*/
|
|
360
|
+
const again = this.scene?.rootContainer?.rebuildContentModel && this.registered === type
|
|
361
|
+
if (!again) this.teardown()
|
|
362
|
+
this.driving = {}
|
|
353
363
|
registerFigure(type, asset)
|
|
354
364
|
this.registered = type
|
|
355
|
-
const size = authoredSizeOf(asset)
|
|
356
365
|
const { components, board } = previewInstances(type, size)
|
|
366
|
+
const model = {
|
|
367
|
+
type: 'model-layer',
|
|
368
|
+
width: board.width,
|
|
369
|
+
height: board.height,
|
|
370
|
+
depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
|
|
371
|
+
threed: true,
|
|
372
|
+
sky: 'color',
|
|
373
|
+
...sceneLook(view),
|
|
374
|
+
placement: view.placement,
|
|
375
|
+
heights: { floor: 0, ceiling: view.ceilingHeight },
|
|
376
|
+
pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
|
|
377
|
+
components
|
|
378
|
+
}
|
|
379
|
+
if (again) {
|
|
380
|
+
try {
|
|
381
|
+
this.scene!.rootContainer!.rebuildContentModel!(model, { preserveThreeD: true })
|
|
382
|
+
this.standing = signature
|
|
383
|
+
this.cannot = ''
|
|
384
|
+
return
|
|
385
|
+
} catch {
|
|
386
|
+
this.teardown()
|
|
387
|
+
registerFigure(type, asset)
|
|
388
|
+
this.registered = type
|
|
389
|
+
}
|
|
390
|
+
}
|
|
357
391
|
this.scene = create({
|
|
358
392
|
target: this.stage,
|
|
359
|
-
model
|
|
360
|
-
type: 'model-layer',
|
|
361
|
-
width: board.width,
|
|
362
|
-
height: board.height,
|
|
363
|
-
depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
|
|
364
|
-
threed: true,
|
|
365
|
-
sky: 'color',
|
|
366
|
-
...sceneLook(view),
|
|
367
|
-
placement: view.placement,
|
|
368
|
-
heights: { floor: 0, ceiling: view.ceilingHeight },
|
|
369
|
-
pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
|
|
370
|
-
components
|
|
371
|
-
},
|
|
393
|
+
model,
|
|
372
394
|
mode: 0
|
|
373
395
|
}) as SceneHandle
|
|
374
396
|
this.standing = signature
|
|
@@ -77,7 +77,39 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/*
|
|
81
|
+
The figure's own settings are shown while no part is picked; with one picked this is out of sight, and working out its
|
|
82
|
+
facts (the figure parsed and compiled) on every edit is for nobody. Out of sight it keeps what it last drew, and it
|
|
83
|
+
draws again when it comes back into view.
|
|
84
|
+
*/
|
|
85
|
+
private lastDrawn?: unknown
|
|
86
|
+
private outOfSight = false
|
|
87
|
+
private watch?: ResizeObserver
|
|
88
|
+
connectedCallback() {
|
|
89
|
+
super.connectedCallback()
|
|
90
|
+
this.watch = new ResizeObserver(() => {
|
|
91
|
+
if (this.outOfSight && this.checkVisibility?.() !== false) {
|
|
92
|
+
this.outOfSight = false
|
|
93
|
+
this.requestUpdate()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
this.watch.observe(this)
|
|
97
|
+
}
|
|
98
|
+
disconnectedCallback() {
|
|
99
|
+
this.watch?.disconnect()
|
|
100
|
+
super.disconnectedCallback()
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
render() {
|
|
104
|
+
if (this.hasUpdated && this.checkVisibility?.() === false) {
|
|
105
|
+
this.outOfSight = true
|
|
106
|
+
return this.lastDrawn ?? nothing
|
|
107
|
+
}
|
|
108
|
+
this.lastDrawn = this.draw()
|
|
109
|
+
return this.lastDrawn
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private draw() {
|
|
81
113
|
const facts = this.facts()
|
|
82
114
|
if (!facts) return nothing
|
|
83
115
|
/* The figure's type is the page's (it lives on the row); it is slotted into the first section. */
|