@hatiolab/figure-model 0.4.0 → 0.4.2

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 (52) hide show
  1. package/dist/asset-types.d.ts +6 -0
  2. package/dist/asset-types.d.ts.map +1 -1
  3. package/dist/asset-types.js.map +1 -1
  4. package/dist/asset.d.ts +12 -0
  5. package/dist/asset.d.ts.map +1 -1
  6. package/dist/asset.js +15 -3
  7. package/dist/asset.js.map +1 -1
  8. package/dist/authoring-actions.d.ts +30 -0
  9. package/dist/authoring-actions.d.ts.map +1 -1
  10. package/dist/authoring-actions.js +91 -8
  11. package/dist/authoring-actions.js.map +1 -1
  12. package/dist/capabilities.d.ts.map +1 -1
  13. package/dist/capabilities.js +71 -11
  14. package/dist/capabilities.js.map +1 -1
  15. package/dist/command-preview.d.ts +6 -0
  16. package/dist/command-preview.d.ts.map +1 -1
  17. package/dist/command-preview.js +35 -2
  18. package/dist/command-preview.js.map +1 -1
  19. package/dist/editor-workspace.d.ts +48 -401
  20. package/dist/editor-workspace.d.ts.map +1 -1
  21. package/dist/editor-workspace.js +55 -13
  22. package/dist/editor-workspace.js.map +1 -1
  23. package/dist/index.d.ts +4 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/kernel-version.d.ts +1 -1
  28. package/dist/kernel-version.js +1 -1
  29. package/dist/proposal.d.ts +1 -1
  30. package/dist/proposal.d.ts.map +1 -1
  31. package/dist/proposal.js +8 -0
  32. package/dist/proposal.js.map +1 -1
  33. package/dist/release-contract.d.ts +1 -1
  34. package/dist/release-contract.d.ts.map +1 -1
  35. package/dist/release-contract.js +27 -4
  36. package/dist/release-contract.js.map +1 -1
  37. package/dist/slot-layout.d.ts +212 -0
  38. package/dist/slot-layout.d.ts.map +1 -0
  39. package/dist/slot-layout.js +333 -0
  40. package/dist/slot-layout.js.map +1 -0
  41. package/dist/slot-places.d.ts +45 -0
  42. package/dist/slot-places.d.ts.map +1 -0
  43. package/dist/slot-places.js +40 -0
  44. package/dist/slot-places.js.map +1 -0
  45. package/dist/work-counter.d.ts +16 -0
  46. package/dist/work-counter.d.ts.map +1 -0
  47. package/dist/work-counter.js +13 -0
  48. package/dist/work-counter.js.map +1 -0
  49. package/docs/modeller-interaction-performance-2026-10-07.md +60 -0
  50. package/docs/performance-followup-2026-10-05.md +267 -3
  51. package/docs/shape-dimension-contract.md +10 -0
  52. package/package.json +2 -1
@@ -184,7 +184,7 @@ figure-model `candidate/extrude-param-cache`(병합하지 않음). 컴파일된
184
184
  매개변수는 매번 읽는다. 결과에는 그 객체가 들어가지 않는다(숫자와 이름만 복사). 시험 `src/shape-params.test.ts` 3개, 결함 3종(동결 여부
185
185
  무시, 결과 faces 공유, 호출자 매개변수 동결)을 심어 실패를 확인했고 기존 759개가 통과했다(figure-model HEAD는 다른 세션의
186
186
  `src/render-lod.test.ts` 타입 오류 하나로 `npm test`가 타입 검사에서 멈춰 있어 `node --test`로 직접 돌렸다. 정상 `npm test`·build 검증은
187
- 그 오류가 고쳐진 뒤에 다시 한다 — 직접 실행한 통과만으로 전체 검증 완료가 아니다).
187
+ 그 오류가 고쳐진 뒤에 다시 한다 — 직접 실행한 통과만으로 전체 검증 완료가 아니다). → 차단 해제: 아래 「타입 수정 뒤 정상 시험·build 검증」(762개 통과, build 성공).
188
188
 
189
189
  측정은 첫 후보와 같다(보드, 크기만 교대, 6 round × D1·D2·C, 순서 여섯 가지, 워밍업 30, 비계측 150프레임). things-scene은 같은 `f287f5f7`이고
190
190
  D는 figure-model HEAD `83737e3`, C는 그 위에 이 후보만 얹었다.
@@ -222,7 +222,7 @@ key·index·shape 포함), 개수 증감, 안쪽 두 행의 개수가 3+2 ↔ 2+
222
222
  path가 복사본마다·평가마다 다른 배열인지. 결함 다섯(멤버 검사 누락, 멤버 수만 검사, path 공유, 합성 순서 변경, rise 누락)을 심어 모두
223
223
  실패를 확인했다. fixture만으로는 멤버 검사 누락과 합성 순서 변경이 통과해서 격자 시험을 더했다. HEAD 구현도 네 시험을 통과한다.
224
224
  직접 실행한 시험 763개 통과, 정상 시험 명령은 기존 타입 오류로 차단(`src/render-lod.test.ts:42`, a5781eb). 정상 `npm test`·build가
225
- 통과해야 최종 검증이 끝난다.
225
+ 통과해야 최종 검증이 끝난다. → 차단 해제: 아래 「타입 수정 뒤 정상 시험·build 검증」(763개 통과, build 성공).
226
226
 
227
227
  측정은 앞 후보들과 같다(보드, 크기만 교대, 6 round × D1·D2·C, 순서 여섯 가지, 워밍업 30, 비계측 150프레임; 60대는 회귀 확인용 2 round).
228
228
  things-scene `f287f5f7`, D = figure-model `a5363bd`, C = 이 후보. D 번들은 매개변수 파싱 측정의 기준 번들과 바이트 단위로 같다.
@@ -241,7 +241,7 @@ things-scene `f287f5f7`, D = figure-model `a5363bd`, C = 이 후보. D 번들은
241
241
  작업량은 같았다: draw call 2001, 삼각형 944012, body 20000, 행 복사 12000(60대: 241, 113292, 2400, 1440).
242
242
 
243
243
  60대(2 round): 프레임 중앙값 차이 −0.22, +0.59 ms(A/A −0.38, −0.11), p95 차이 +4.01, +0.13 ms(A/A +0.28, −0.61). 커널 2.47 → 2.35 ms
244
- (D2도 2.35), 할당 10.49~10.70 → 10.41~10.42 MB/frame, GC 0.44 → 0.42 ms, 보유 10.1 → 10.3 MB. 검증한 시험과 측정 장면에서 기능 차이를 발견하지 못했다(정상 시험 명령은 아직 차단).
244
+ (D2도 2.35), 할당 10.49~10.70 → 10.41~10.42 MB/frame, GC 0.44 → 0.42 ms, 보유 10.1 → 10.3 MB. 검증한 시험과 측정 장면에서 기능 차이를 발견하지 못했다(정상 시험 명령 검증은 아래 「타입 수정 뒤 정상 시험·build 검증」).
245
245
  성능 회귀는 미확정이다 — 첫 회차 p95가 A/A 밖으로 나빠졌고, 2회로는 반복되는지 가를 수 없다.
246
246
 
247
247
  원인 확인(별도 할당 프로필, 500대 3 round, 이 실행의 프레임 시간은 판단에 쓰지 않는다): 실제로 서빙된 번들
@@ -312,3 +312,267 @@ rig가 그린 것에 닿지 않는지(`test/figure-merge.check.js`). 결함 다
312
312
  약 0.6 ms, 60대에서 약 0.1 ms 늘었다. 60대 프레임 중앙값은 두 회차 모두 A/A보다 느렸다(성능 회귀 미확정). 500대 프레임
313
313
  효과는 이 실행의 A/A 폭 안이라 확인하지 못했다. 시간이 늘어난 이유는 추론이다: 최종 body를 `{ id }`에서 시작해 속성 8~9개를 차례로
314
314
  더하면 객체 안의 자리를 넘어 별도 속성 저장소가 생길 수 있고, 펼치기는 처음부터 맞는 크기로 만든다 — 확인하지 않았다.
315
+
316
+ ### P1 네 번째: `fromChanges`의 최종 body를 객체 리터럴 하나로 — 기본 채택 보류 (2026-10-05)
317
+
318
+ 세 번째 후보(B)에서 `fromChanges` 시간이 늘어난 이유로 「속성을 차례로 붙이면 객체 밖 속성 저장소가 생긴다」를 가설로 적었다. 먼저 작은
319
+ 비교로 확인했다(node, 같은 Figure, rig 500개, 두 크기 교대, 워밍업 30·측정 60프레임, `fromChanges`만 시간 측정, 변형마다 별도 프로세스,
320
+ A B C C B A 순서 두 번 = 변형마다 4회, 반복 수는 미리 고정).
321
+
322
+ | 변형 | `fromChanges` ms/frame 중앙값 (범위) | 최종 body의 속성 자리(V8 `%DebugPrint`) |
323
+ | --------------------------------- | ------------------------------------ | ---------------------------------------- |
324
+ | A 기존(91a5ac30, 펼치기) | 2.42 (2.30~2.58) | id만 객체 안, 나머지 8개는 PropertyArray |
325
+ | B 세 번째 후보(차례로 추가) | 2.87 (2.67~3.56) | A와 같음 |
326
+ | C 객체 리터럴 | 2.37 (2.33~2.43) | 9개 모두 객체 안 |
327
+
328
+ 가설은 맞지 않았다 — A도 객체 밖 저장소를 쓰므로 그것으로는 B가 A보다 느린 것을 설명하지 못한다(B가 느린 이유는 확인하지 못했다). C는 B보다
329
+ 빠르고 A와 비슷했고, A·B가 body마다 만드는 PropertyArray를 만들지 않으므로 별도 후보로 전체 프레임을 측정했다.
330
+
331
+ things-scene `candidate/from-changes-literal` `2b27d899`(B `3e7101e6` 위, 병합하지 않음). 실제로 나오는 네 모양 — 행 복사본(path·key·index)과
332
+ 배치 부품(셋 다 없음), 각각 segments 있음·없음 — 을 객체 리터럴 하나로 만들고, 그 밖의 조합은 같은 순서로 하나씩 붙인다(fixture와 측정
333
+ Figure 어디서도 나오지 않는다). 결함 넷(행+segments 필드 순서, 부품+segments의 segments 누락, segments 없는 행의 index 누락, 부품의
334
+ visible 고정)을 심었을 때 셋째가 잡히지 않았다 — segments 없는 행은 `cell-tray`에만 있고 변경분 시험에 없었다. `cell-tray`를 변경분과
335
+ 스냅샷 비교 시험에 더해 넷 모두 실패를 확인했다(더한 시험은 A·B·C 모두 통과). `npm run test:figure` 92개, `npm test` 2715개 통과.
336
+
337
+ 측정: D = things-scene `91a5ac30`(세 번째 후보와 같은 번들, 서빙 번들 sha256 `a82cb950…f944`도 같다), C = 이 후보, figure-model 둘 다
338
+ `a5363bd` dist. 앞 후보들과 절대 시간을 비교하지 않는다.
339
+
340
+ | 500대 | A/A (D2−D1) round별 | 후보 (C−D1) round별 |
341
+ | ---------------------- | -------------------------------------- | --------------------------------------- |
342
+ | 프레임 중앙값 차이 ms | +0.44 −9.32 +5.20 −1.66 −0.22 +0.07 | +0.61 −9.57 +1.54 −1.71 +0.63 +0.67 |
343
+ | p95 차이 ms | +0.56 −9.82 +4.04 −2.81 −0.47 −0.71 | −0.09 −11.97 +2.00 −6.42 +2.27 −0.54 |
344
+
345
+ | 500대 | D1 | D2 | C |
346
+ | ----------------------------- | ------------ | ------------ | ------------ |
347
+ | 할당 MB/frame(6회) | 76.96~77.28 | 76.90~77.29 | 74.80~75.46 |
348
+ | 보유 MB(측정 뒤 GC) | 42.7~43.3 | 42.7~43.3 | 43.0 |
349
+ | 단계 계측 `fromChanges` ms | 2.17 | 1.99 | 1.88 |
350
+ | 단계 계측 커널 / GC ms | 21.20 / 4.91 | 19.91 / 4.67 | 19.91 / 4.35 |
351
+
352
+ A/A가 2회차 −9.3 ms, 3회차 +5.2 ms로 크게 벌어졌다 — 실행 간 변동이 컸다는 근거이고 원인은 확인하지 않았다. 그 폭 안에서 후보의 프레임
353
+ 효과는 가를 수 없다. 작업량은 같았다(draw call 2001, 삼각형 944012, 장면 body 20000, 행 복사 12000; 60대 241, 113292, 2400, 1440).
354
+
355
+ 60대(2 round): 프레임 중앙값 차이 0.00, +0.11 ms(A/A +0.02, +0.14), p95 −0.30, +0.13 ms(A/A −0.34, +0.55), 단계 계측 `fromChanges`
356
+ 0.20·0.22 → 0.28 ms, 할당 10.53 → 10.23~10.30 MB/frame, GC 0.44·0.47 → 0.40 ms, 보유 같음(10.1).
357
+
358
+ 원인 확인(별도 할당 프로필, 500대 3 round): 서빙 번들 사본 `figure-vs-js/from-changes-literal-2026-10-05/`(D sha256 `a82cb950…f944`,
359
+ C `97744f65…861c`), `fromChanges`는 둘 다 0부터 센 97275행 332754열, `test/browser/figure-allocation-under.mjs`로 집계. `fromChanges`
360
+ (하위 호출 포함) D1 7.909 · D2 7.892 · C 5.955 MiB/frame(약 1.95 MiB, 24.6% 감소), 전체 77.05 · 77.12 · 75.07(약 2.0 MiB, 2.6%).
361
+ 변경 body 수는 이 후보 C(`2b27d899`)에서 직접 다시 셌다(세 번째 후보와 같은 별도 계측 스크립트, 비계측 비교와 분리; rig 500개, 30프레임):
362
+ 프레임마다 changes step 500, 변경분이 알린 body 20000, 새로 만든 최종 body 20000, structure 0 — 세 번째 후보의 A·B 값과 같다.
363
+
364
+ 관찰 정리: 할당은 `fromChanges`에서 약 2 MiB/frame 줄었다. 단계 계측의 `fromChanges` 시간은 500대에서 A의 두 실행
365
+ (2.17·1.99)보다 낮았고, 60대에서는 0.20·0.22 → 0.28 ms로 높았다. 500대 GC 시간은 두 A보다 낮았다(4.91·4.67 → 4.35 ms) — 할당 감소에서
366
+ GC 감소를 끌어내지 않고 측정값으로만 적는다. 전체 프레임 개선은 이 실행의 A/A 폭 때문에 확인하지 못했다. 60대 프레임은 A/A와 구분되지 않았다.
367
+
368
+ 판정: 기본 채택 보류, 후보와 기록을 보존한다. 할당 2.6% 감소는 확인했지만, 전체 프레임 개선은 구분되지 않았고 60대의 `fromChanges`
369
+ 시간은 늘었다. 다음은 미세 최적화보다 측정의 실행 간 변동 원인을 먼저 확인한다(A/A −9.32~+5.20 ms).
370
+
371
+ ### 타입 수정 뒤 정상 시험·build 검증 (2026-10-05)
372
+
373
+ `src/render-lod.test.ts:42`의 타입 오류는 다른 세션이 main `d6bdd66`(사용자 지시)으로 고쳤다. 고친 diff는 시험 쪽 한 줄이다:
374
+
375
+ ```diff
376
+ - assert.equal(figureRenderSegmentsOf(unsupported, 'coarse'), 32)
377
+ + assert.equal(figureRenderSegmentsOf(unsupported as never, 'coarse'), 32)
378
+ ```
379
+
380
+ 후보 브랜치는 그대로 두고, 따로 만든 분리 worktree에서 후보의 기준 커밋에 `d6bdd66`만 얹어(`git cherry-pick -n`, 커밋하지 않음) 정상
381
+ `npm test`(타입 검사 + 시험)와 `npm run build`를 돌렸다. main 전체로 rebase하지 않았다 — 측정한 대상과 같게 두기 위해서다.
382
+
383
+ | 대상 | 기준 커밋 | 얹은 것 | `npm test` | build |
384
+ | ---------------------------------------- | --------- | ------------- | ---------------------- | ----- |
385
+ | figure-model main | `5d8db89` | (d6bdd66 포함) | 759 통과, 0 실패 | 성공 |
386
+ | `candidate/repeat-run` (P2 첫 후보) | `07625f8` | `d6bdd66` | 763 통과, 0 실패 | 성공 |
387
+ | `candidate/extrude-param-cache` (P1 둘째) | `fc9a7b3` | `d6bdd66` | 762 통과, 0 실패 | 성공 |
388
+
389
+ 두 후보의 「정상 시험 명령은 타입 오류로 차단」 상태는 이 검증으로 해제한다. 성능 판정(둘 다 기본 채택 보류)은 바뀌지 않는다.
390
+ things-scene 후보(`candidate/from-changes-dress`, `candidate/from-changes-literal`)는 이 오류와 무관하게 `npm test`(2715)와
391
+ `npm run test:figure`(92)를 통과했다. 발행하지 않았다.
392
+
393
+ ### 측정의 실행 간 변동 진단 (2026-10-05, 후보 판정과 별개)
394
+
395
+ 후보 코드 변경 없이 진행했다. 보류한 후보들의 성능 판정을 대신하지 않는다.
396
+
397
+ 1. 기존 결과 다시 보기(새 측정 없음). 오늘 500대 6 round 다섯 번의 `gap-*.json`을 실행 순서(ORDERS)대로 늘어놓았다. 실행 18회의 프레임
398
+ 중앙값 범위는 형상 키 49.3~51.6, 매개변수 파싱 48.8~52.0, `repeat@1` 48.8~53.9(13~17번째 실행 구간), `fromChanges` B 49.5~56.8
399
+ (11~16번째), 리터럴 C 49.8~60.3 ms(6~10번째)였다. 서서히 변하는 흐름은 없었고, 연속된 몇 번의 실행이 함께 느려졌으며 그때 돈 팔이
400
+ 무엇이든 느렸다. round별 A/A 차이가 크게 나온 것은 이 구간이 어느 round에 걸렸는지와 맞물린다. 그 시각의 부하·열 상태는 기록되지 않았다.
401
+
402
+ 2. 고정 측정. things-scene `82b84ead`의 `TRACE=1`(실행 앞뒤에서만 기록하며 측정 프레임 안에는 아무것도 더하지 않는다). 세 팔 모두 같은 build
403
+ (things-scene `91a5ac30`, figure-model `a5363bd`), 보드 500대, 6 round, 워밍업 30, 측정 150, 프로필 없음. 원자료
404
+ `figure-vs-js/gap-1791206726787.json`. 워밍업과 측정 구간은 같은 `frame()`(크기 변경 + 그리기 + 1픽셀 읽기를 `performance.now()`로)과
405
+ 프레임 사이 `requestAnimationFrame` 한 번으로 잰다 — 타이밍 방식이 같으므로 두 구간의 추이를 비교할 수 있다. 다만 워밍업 초반에는
406
+ 초기 컴파일·캐시 영향이 들어 있다.
407
+
408
+ | 항목 | 결과 |
409
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
410
+ | A/A (D2−D1) round별 중앙값 / p95 ms | +1.31 +1.25 −0.46 +0.08 −0.20 +0.50 / +2.84 −0.17 −2.95 +0.22 +0.14 +2.44 |
411
+ | 같은 build (C−D1) round별 중앙값 / p95 ms | +0.02 +0.06 −0.10 −0.10 −1.06 +0.06 / +1.00 −0.71 −2.60 +0.74 −0.04 −0.41 |
412
+ | 실행 18회 중앙값 | 1~3번째(첫 round) 53.5·54.8·53.5, 4~18번째 49.4~51.7 ms |
413
+ | 1분 부하 평균(실행 직전) | 1~3번째 5.8·5.2·5.7, 4~18번째 2.9~4.5 |
414
+ | 열·성능 경고(`pmset -g therm`) | 18회 모두 기록 없음 |
415
+
416
+ 이번 실행에서는 앞의 세 측정 같은 연속 느린 구간이 나오지 않았고, A/A 중앙값 차이는 −0.46~+1.31 ms였다(앞의 측정 −9.32~+6.77).
417
+
418
+ - 부하와의 상관관계 후보: 처음 세 실행이 느렸고 그때 1분 부하 평균이 가장 높았다. 그 시각 CPU 상위에 다른 세션의 `npm run build`,
419
+ CPU 169%인 `node`, Spotlight 색인(`mds_stores`), `syspolicyd`가 있었다. 다만 첫 round라는 순서 효과와 겹쳐 있어 둘을 가를 수 없다.
420
+ 실행 중간에도 다른 앱(ChatGPT Codex 140%, `node` 231% 등)이 CPU 상위에 있었지만 그 실행들은 느리지 않았다. 상관관계로만 적는다.
421
+ - 정리 확인: 18회 모두 정리 뒤 WebGL context가 해제되었고(`isContextLost` true), renderer 기록의 geometry는 5 → 0이었다(texture 4 → 2,
422
+ program 3 → 3은 해제 뒤에도 renderer 기록에 남은 수). 열린 target 수는 매번 3 → 3으로 돌아왔고, 페이지 시작 시 JS heap은 18회 모두
423
+ 7.5 MB로 같았다. 이것은 페이지 단위 정리 확인이며 브라우저 전체(GPU 프로세스 등)에 자원이 남지 않았다는 뜻은 아니다. GPU 프로세스의
424
+ 누적 CPU 시간은 실행마다 약 1.7 s씩 늘었는데, 누적값이라 잔류 여부를 보여 주지 않는다.
425
+ - 워밍업 30프레임 뒤의 안정화: 측정 150프레임의 처음 10프레임 중앙값이 마지막 10프레임 중앙값보다 큰 실행이 18회 중 15회였다.
426
+ 차이는 +0.1~+7.6 ms(중앙값 약 +2.5 ms), 반대인 실행은 셋(−0.9, −2.1, −8.0 ms)이었다. 보조 지표인 기울기(150프레임 동안 변화량)는
427
+ 중앙값 약 −1.4 ms였고 +5.8 ms인 실행 하나가 있었다(GC 같은 순간적인 튐에 민감하다). 워밍업 30프레임 뒤에도 대부분의 실행에서 시간이
428
+ 계속 낮아지고 있었다 — 측정 구간이 아직 안정 상태가 아니다.
429
+
430
+ 결론: 앞의 측정들에서 A/A가 크게 벌어진 원인은 미확정이다. 확인한 것은 (1) 연속된 느린 실행 구간이 있었다는 것, (2) 부하 평균이 높았던
431
+ 시각과 느린 실행이 겹친 사례가 한 번 있었다는 것(상관관계), (3) 워밍업 30프레임 뒤에도 측정 구간 안에서 시간이 대개 2~3 ms 더 낮아진다는
432
+ 것이다. (3)은 판정 전 측정 조건(워밍업 길이)을 다시 정할 근거가 될 수 있다 — 바꾸기 전에 사용자와 정한다.
433
+
434
+ ### 측정 조건 검증: 워밍업 300 · 측정 300 (2026-10-05, 측정 전에 고정)
435
+
436
+ 후보 비교 없이 같은 build로 조건만 검증한다. 세 팔 모두 things-scene `91a5ac30` · figure-model `a5363bd`, 보드 500대, 기존 ORDERS 6 round(실행
437
+ 18회), 워밍업 300, 측정 300, `TRACE=1`, 프로필 없음. 안정화는 실행마다 측정 프레임 1~100, 101~200, 201~300의 중앙값으로 본다.
438
+ 안정으로 보는 기준(측정 전에 정함): 실행 18회의 (1~100 중앙값 − 201~300 중앙값)의 중앙값이 ±0.5 ms 안이고, 양수인 실행이 18회 중 12회 이하.
439
+ 결과를 보고 워밍업을 더 늘리지 않는다. 부하 평균으로 실행을 고르거나 빼지 않는다. 측정 동안 다른 세션(허드렛일 담당, Operato-board
440
+ figure-model 운영, Operato-twin 페이지 UI/UX 개선)에 새 build·test·분석 작업을 미뤄 달라고 요청했다. Spotlight 등 나머지 부하는 기록한다.
441
+ 안정되면 리터럴 후보부터 같은 조건으로 다시 비교하고, 안정되지 않으면 작은 후보의 판정을 계속 보류하고 측정 원인을 더 확인한다.
442
+
443
+ 결과(원자료 `figure-vs-js/gap-1791207788161.json`, 실행 18회 13:28:41Z~13:42:16Z):
444
+
445
+ - 실행 안 안정화 — 미리 정한 기준 충족. (1~100 중앙값 − 201~300 중앙값)의 중앙값 +0.13 ms(기준 ±0.5 안), 양수 10회(기준 12회 이하),
446
+ 범위 −3.8~+7.0 ms. 워밍업 300프레임 뒤의 측정 구간에서는 앞선 30프레임 조건에서 본 「계속 낮아지는」 추세가 보이지 않았다.
447
+ - 실행 사이 변동 — 남았다. 실행 중앙값은 1번째 57.6, 2~13번째 48.9~50.5, 14·17·18번째 53.7·54.6·53.1, 15번째 50.7 ms. A/A(D2−D1)
448
+ round별 중앙값 −7.08 −0.89 +0.63 −1.02 +2.95 −5.30, p95 −10.28 −3.41 +0.63 −6.30 +2.83 −5.51. 같은 build(C−D1) 중앙값 −8.23 −1.08
449
+ +0.71 −0.67 −1.60 −1.54. 느린 실행이 낀 round(1·5·6)에서 차이가 크다. 느린 실행 셋(14·17·18)은 그 안에서 시간이 오르거나(14·17:
450
+ 가운데·마지막 구간이 높다) 처음이 높았다(18) — 실행 도중의 변화다.
451
+ - 다른 작업과 시각(상관관계 후보, 원인 확정 아님): 1번째 실행(13:28:41Z) 직전 1분 부하 평균 25.6, CPU 상위가 `node` 셋. 같은 무렵
452
+ Operato-twin UX 세션의 파일 저장으로 :4000 webpack이 한 번 다시 묶었을 수 있다(13:28:39Z 무렵, 그 세션의 알림). 허드렛일 담당 세션의
453
+ tf 발행은 13:36:04Z 응답 시각에 「방금 끝났다」였고 시작 시각을 몰라 어느 실행과 겹쳤는지는 미확정이다. Operato-twin UX 세션은 사용자
454
+ 지시로 22:38(13:38Z, 분 단위; 그 알림의 도착 13:38:09Z)부터 plant `npm test`, twin client tsc, :3000 twin 개발 서버 기동을 다시
455
+ 시작했다 — 12번째 실행(13:37:28Z~13:38:15Z)은 실행 도중에 이 작업이 시작되어 겹쳤고, 13~18번째(13:38:15Z 이후 시작)는 시작 전부터
456
+ 겹쳤다. 느린 실행 14·17·18은 모두 13:38Z 이후다(15·16번째는 느리지 않았다). 부하 평균은 1번째를 빼면 3.9~7.8로 느린 실행과 일관되게
457
+ 대응하지 않았다. 열·성능 경고는 18회 모두 없었다. 실행은 하나도 빼지 않았다.
458
+ - 정리 확인(페이지 단위): 18회 모두 context 해제, 열린 target 3 → 3, 페이지 시작 JS heap 7.5 MB.
459
+
460
+ 판단: 워밍업 300 · 측정 300으로 실행 안의 안정화는 18회를 모아 본 기준을 충족했다 — 모든 실행이 안정적이었다는 뜻은 아니다(14·15·17·18번째는
461
+ 실행 안에서 구간 중앙값이 2~7 ms 움직였다). 실행 사이의 변동은 다른 작업이 돌던 시각의 실행에서 크게 남아,
462
+ 같은 build의 A/A가 round에 따라 ±5~8 ms 벌어졌다. 원인은 미확정이다(다른 작업과 시각이 겹친 것은 상관관계).
463
+
464
+ 다음(사용자 결정): 다른 세션의 사용자 지시 작업은 멈추게 하지 않고, 그 작업이 끝난 조용한 시간대에 리터럴 후보를 같은 조건(워밍업 300 ·
465
+ 측정 300 · 18회, D1·D2 A/A 대조)으로 다시 비교한다. 실행 제외나 결과에 따른 회차 추가 없이 판단하고, 다시 변동에 묻히면 후보는 보류로
466
+ 마무리한다.
467
+
468
+ ### 리터럴 후보 재비교: 워밍업 300 · 측정 300 · 18회 — 보류로 마무리 (2026-10-05)
469
+
470
+ D = things-scene `91a5ac30`, C = `candidate/from-changes-literal` `2b27d899`, figure-model 둘 다 `a5363bd` dist, 보드 500대, 기존 ORDERS 6 round,
471
+ 워밍업 300, 측정 300, `TRACE=1`, 프로필 없음. 실행 18회 13:47:49Z~14:02:20Z, 제외 없음. 원자료 `figure-vs-js/gap-1791208940162.json`.
472
+
473
+ 조건 기록: build·test는 억제했지만 파일 저장에 따른 개발 서버 재빌드는 남은 상태다. 허드렛일 담당은 13:46:42Z부터, Operato-twin UX는
474
+ 13:47:27Z부터 30분 동안 새 build·test·tsc·서버 기동을 하지 않기로 했고, Operato-twin UX는 사용자 지시로 화면 코드 수정을 계속했다(저장마다
475
+ 떠 있는 :3000의 tsc --watch·webpack과 :4000 webpack이 몇 초씩 다시 묶는다). Operato-board figure-model 운영은 측정 시작 뒤 13:56:42Z에 「작업
476
+ 끝, 30분 동안 새 build·test 없음, :3600 서버는 떠 있되 쓰지 않음」이라 답했다 — 1~10번째 실행 동안의 그 세션 작업은 확인하지 못했다. 시작
477
+ 직전 CPU 상위에 build·test 프로세스는 없었고 1분 부하 평균 3.27이었지만, 이것이 측정 도중의 무부하를 보장하지 않는다. 7번째 실행
478
+ (13:52:40Z) 직전 CPU 상위에 `tsc … tsconfig.json --noEmit`이 있었다(어느 세션 것인지 확인하지 못했다).
479
+
480
+ 측정 시간 안에 저장된 소스 파일(수정 시각, Z): 13:48:01 twin 페이지(1번째 실행 중), 13:49:44 board `board-figures.ts`·13:49:53 board 번역
481
+ 다섯(3번째 실행 중), 13:55:24 twin 페이지(10번째), 13:58:27 twin 페이지(14번째), 14:01:16 twin 페이지와 번역 다섯(17번째 끝 무렵)·14:01:38 twin
482
+ 번역 캐시(18번째 실행 중).
483
+
484
+ | 실행 | 시작 Z | 팔 | 중앙값 | p95 | 구간 중앙값(1~100 / 101~200 / 201~300) | 그 실행 중 저장 |
485
+ | ---- | --------- | -- | ------ | ---- | -------------------------------------- | --------------------- |
486
+ | 1 | 13:47:49 | D1 | 50.4 | 59.7 | 49.9 / 49.7 / 51.8 | 13:48:01 |
487
+ | 2 | 13:48:38 | D2 | 52.8 | 61.0 | 50.6 / 55.4 / 53.1 | 없음 |
488
+ | 3 | 13:49:28 | C | 50.2 | 57.0 | 50.0 / 50.8 / 50.0 | 13:49:44 · 13:49:53 |
489
+ | 4~11 | 13:50:17~ | — | 49.2~50.6 | 53.3~58.2 | 차이 1 ms 안팎 | 13:55:24(10번째) |
490
+ | 12 | 13:56:36 | D2 | 56.5 | 67.7 | 56.7 / 57.9 / 54.1 | 없음 |
491
+ | 13·14 | 13:57:28~ | C·D2 | 50.1·49.4 | 55.9·55.2 | — | 13:58:27(14번째) |
492
+ | 15 | 13:59:03 | D1 | 54.6 | 61.8 | 55.3 / 55.9 / 51.9 | 없음 |
493
+ | 16·17 | 13:59:53~ | D2·D1 | 50.1·50.5 | 58.3·57.1 | — | 14:01:16(17번째 끝 무렵) |
494
+ | 18 | 14:01:30 | C | 52.5 | 61.6 | 50.7 / 51.7 / 56.0 | 14:01:38 |
495
+
496
+ 느린 실행 넷(2·12·15·18) 가운데 저장과 겹친 것은 18번째 하나이고, 저장이 있었던 1·3·10·14번째는 느리지 않았다 — 저장 재빌드와 느린 실행은
497
+ 일관되게 대응하지 않는다(상관관계로도 약하다).
498
+
499
+ 정정(측정 뒤 Operato-twin UX 세션의 알림, 시각은 그 세션이 적은 KST를 Z로 옮긴 것이며 대략값): 그 세션은 측정 동안 저장 말고도 직접
500
+ 실행한 작업이 있었다 — 13:49:01 저장으로 한 파일이 117만 줄이 되어 `tsc --watch`가 그것을 처리했고(13:51 `git checkout`으로 되돌림),
501
+ `tsc --noEmit`(13:49~13:50, 13:52:4x, 13:59~14:01 두 번), 전체 `tsc` emit(13:56:03~06), twin `npm test`(13:56:44, 13:59:11, 14:01:55, 각 약 1분).
502
+ 같은 목록의 operato-board·webcomponents 파일(13:47~13:49)은 그 세션 것이 아니다(누구 것인지 확인하지 못했다). 실행 구간과 맞추면:
503
+
504
+ | 느린 실행 | 그 실행과 겹친 작업 |
505
+ | --------- | ------------------- |
506
+ | 2 (52.8) | 117만 줄 파일 저장과 `tsc --watch` 처리, `tsc --noEmit` |
507
+ | 12 (56.5) | twin `npm test` 시작(13:56:44) |
508
+ | 15 (54.6) | twin `npm test` 시작(13:59:11), `tsc --noEmit` |
509
+ | 18 (52.5) | twin `npm test` 시작(14:01:55) |
510
+
511
+ 느린 실행 넷이 모두 무거운 작업(큰 파일의 tsc 처리 또는 `npm test` 시작)과 겹쳤다. `tsc --noEmit`이나 저장만 겹친 실행(3·4·7·14·17)과
512
+ `npm test` 끝자락만 겹친 실행(13·16)은 느리지 않았다. 앞 문단의 「일관되게 대응하지 않는다」는 저장 재빌드에 대해서만 맞고, 직접 실행된
513
+ 작업까지 보면 느린 실행과 일관되게 겹친다. 시각이 대략값이고 한 측정의 관찰이므로 상관관계로 적으며, 원인으로 확정하지 않는다. 이 측정은
514
+ 「build·test 억제」 조건을 다 지키지 못한 상태였다. 열·성능 경고는 18회 모두 없었다. 정리 확인(페이지 단위)은 18회 모두 context 해제, target 3 → 3,
515
+ 시작 JS heap 7.5 MB. 실행 안 안정화: (1~100 − 201~300) 중앙값 +0.38 ms, 양수 12회 — 미리 정한 기준(±0.5, 12회 이하)을 겨우 충족했다(18회를
516
+ 모아 본 판단이며 2·12·15·18번째는 실행 안에서도 움직였다).
517
+
518
+ | 500대 | A/A (D2−D1) round별 | 후보 (C−D1) round별 |
519
+ | ---------------------- | -------------------------------------- | --------------------------------------- |
520
+ | 프레임 중앙값 차이 ms | +2.35 −0.24 −0.18 +6.77 −5.27 −0.42 | −0.26 +1.19 −0.15 +0.85 −4.50 +2.00 |
521
+ | p95 차이 ms | +1.29 −2.40 +0.79 +11.41 −6.62 +1.23 | −2.68 +2.51 +0.39 +1.43 −5.89 +4.49 |
522
+
523
+ | 500대 | D1 | D2 | C |
524
+ | ----------------------------- | ------------ | ------------ | ------------ |
525
+ | 프레임 중앙값 / p95 (6회 중앙값) | 50.08 / 56.68 | 49.81 / 57.32 | 50.36 / 57.33 |
526
+ | 할당 MB/frame(6회) | 77.17~77.52 | 76.97~77.38 | 74.91~75.28 |
527
+ | 보유 MB(측정 뒤 GC) | 42.8~43.4 | 42.9~43.4 | 42.6~43.2 |
528
+ | 단계 계측 `fromChanges` ms | 1.86 | 1.99 | 2.09 |
529
+ | 단계 계측 커널 / GC ms | 20.06 / 4.71 | 20.43 / 4.90 | 20.37 / 4.41 |
530
+
531
+ 작업량은 같았다(draw call 2001, 삼각형 944012, 장면 body 20000, 행 복사 12000).
532
+
533
+ 판정: 보류로 마무리한다. 후보와 기록을 보존한다. 할당은 약 2 MB/frame 줄었지만, 프레임 중앙값·p95 차이는 같은 실행의 A/A 변동
534
+ (중앙값 −5.27~+6.77, p95 −6.62~+11.41 ms) 안에 묻혔고, 6회 중앙값으로도 C가 두 A보다 낫지 않았다. 단계 계측의 `fromChanges`는 두 A보다
535
+ 높았다. 실행 사이 변동의 원인은 미확정이지만, 이 측정에서는 느린 실행 넷이 모두 다른 세션의 무거운 작업과 겹쳤다(위 정정, 상관관계).
536
+
537
+ ### 조용한 시간대의 A/A 진단 (2026-10-05, 측정 전에 고정)
538
+
539
+ 같은 build(things-scene `91a5ac30` · figure-model `a5363bd`)를 세 팔 모두에, 보드 500대, 기존 ORDERS 6 round(18회), 워밍업 300, 측정 300,
540
+ `TRACE=1`, 프로필 없음. 시작 조건: 세 세션(허드렛일 담당, Operato-board figure-model 운영, Operato-twin 페이지 UI/UX 개선)이 모두 「build·test·tsc·
541
+ 서버 기동·파일 저장이 없는 25분」을 알려 온 뒤. 측정 중 CPU 기록: 5초마다 UTC 시각, 부하 평균(`sysctl vm.loadavg`), CPU 상위 8개 프로세스
542
+ (`ps -Ao pcpu,pid,etime,command -r`)를 파일에 적는다. 수집 한 번의 비용은 CPU 약 19 ms(10회에 user 0.06 s + sys 0.13 s)로, 5초 간격이면 한 코어의
543
+ 약 0.4%다. 기록은 측정 프레임과 맞춰 보지 않고 실행 구간 단위로 맞춘다.
544
+ 판단: 실행을 제외하거나 회차를 늘리지 않는다. 측정 중 다른 작업이 끼면 「협의한 조건을 충족하지 못한 측정」으로 기록하고, 추가 재측정 대신
545
+ 별도 머신이나 전용 측정 시간 확보를 다음 선택으로 둔다. 조용한 조건에서 변동이 줄면 외부 부하 설명을 뒷받침하지만, 원인을 단독으로
546
+ 확정하지는 않는다. 후보 채택과 발행은 계속 보류한다.
547
+
548
+ 결과(원자료 `figure-vs-js/gap-1791210931321.json`, CPU 기록 168개, 실행 18회 14:21:28Z~14:35:31Z, 제외 없음):
549
+
550
+ 조건 — 협의한 조건을 다 충족하지 못한 측정이다.
551
+ - 시작 판단의 실수: 허드렛일 담당의 확인이 14:21:27Z에 왔는데 14:13Z쯤으로 잘못 보고 바로 시작했다. 예상 끝(14:37Z)이 Operato-board의 구간
552
+ (14:07:35Z부터 25분)을 넘었고, 측정 중 14:22:18Z에 같은 세션(세션 id 같음)이 14:40Z까지 늘려 주었다. 실제 끝은 14:35:31Z로 세 세션이 알려 온
553
+ 구간 안이다. Operato-twin UX는 「비어 있음, 25분 약속은 못 함」이었다.
554
+ - 측정 직전에 개발 서버 셋이 기동했다: plant `:4000`(14:20:42Z), office(14:20:49Z), twin `--webpack`(14:21:04Z) — 각각 `npm run build:server &&
555
+ copy:files && concurrently tsc --watch + things-factory-dev`. 시작 25~45초 전이라 첫 컴파일·묶음이 1~2번째 실행과 겹쳤을 수 있다. 어느 세션이
556
+ 띄웠는지는 확인하지 못했다. CPU 기록에 잡힌 것은 그 `tsc --watch`(14:22:33Z 8.8%)와 twin `things-factory-dev`(14:23:08Z 7.5%)뿐이다.
557
+ - 그 밖의 부하: Spotlight(`mds`·`mds_stores`, 시작 무렵 최대 약 150%, 15~17번째 실행 중 평균 28~50%), 사용자 Chrome(1~3·18번째 평균 34~90%),
558
+ `mediaanalysisd`(5번째 42%). 1분 부하 평균은 1번째 11.5, 2번째 6.3, 이후 2.3~3.8. 새로 시작된 build·test(`npm test`, `tsc --noEmit` 등)는 CPU
559
+ 기록에 없었다(5초 간격이므로 짧은 작업은 빠질 수 있다).
560
+
561
+ | 항목 | 결과 |
562
+ | ---------------------------------------- | --------------------------------------------------------------------- |
563
+ | A/A (D2−D1) round별 중앙값 / p95 ms | −0.25 −0.22 −0.49 −0.47 +0.22 +0.13 / +0.14 +1.13 −0.09 −0.37 +0.96 +0.48 |
564
+ | 같은 build (C−D1) round별 중앙값 / p95 ms | −0.80 −0.04 −0.75 −0.50 −0.12 −0.22 / +0.57 +1.09 −0.92 −1.02 +0.16 −0.36 |
565
+ | 실행 18회 중앙값 | 49.0~50.3 ms (1·2번째 50.3·50.0) |
566
+ | 실행 안 안정화 | (1~100 − 201~300) 중앙값 +0.30 ms, 양수 14회 — 미리 정한 기준(12회 이하) 밖 |
567
+
568
+ 해석: 같은 build의 A/A 중앙값 차이가 −0.49~+0.22 ms(앞의 측정들 ±5~9 ms)로 줄었고, 느린 실행 구간이 없었다. 다른 세션의 무거운 작업(`npm test`,
569
+ 큰 파일의 tsc 처리)이 없던 이번 측정에서 변동이 줄었으므로 외부 부하 설명을 뒷받침한다. 원인을 단독으로 확정하지는 않는다 — 개발 서버 기동,
570
+ Spotlight, 사용자 Chrome 부하가 있었는데도 변동이 작았다는 점에서, 어떤 부하가 영향을 주는지는 가려지지 않았다. 측정 구간 안의 추세는 양수
571
+ 실행이 14회로 기준(12회 이하)을 넘었다(차이의 중앙값 +0.30 ms는 기준 안).
572
+ 이번 A/A 변동은 작았지만, 1 ms 효과의 검출 가능성은 아직 검증하지 않았다. 실행 안 안정화 기준 둘 가운데 하나를 충족하지 못했으므로
573
+ 완전히 안정됐다고 판단하지 않는다.
574
+
575
+ 다음 선택(사용자 결정, 10-06): 전용 측정 시간 확보. 이 머신에서도 변동이 줄어든 근거가 있으므로 별도 머신으로 옮기기 전에 이 조건을 먼저 제대로
576
+ 확보한다. 그 시간에는 build·test·tsc·파일 저장·개발 서버 기동을 멈추고 측정용 브라우저만 쓴다. 확인 시각과 종료 예정 시각을 명시해 구간을
577
+ 확보한 뒤, 기존 조건(워밍업 300 · 측정 300 · 18회)으로 A/A를 먼저 검증한다. 그 전에는 추가 측정을 하지 않는다.
578
+ 후보 채택과 발행은 계속 보류한다.
@@ -107,6 +107,16 @@ range, and says how it knows:
107
107
  - The endpoint-only shortcut is **wrong** for a rotating long part: inside at both end angles, out in the middle.
108
108
  That case takes the `bounded` row, not the `proven` one.
109
109
 
110
+ ### The occupancy is the instance's box on a board (ADR-0087, amendment 2026-10-06)
111
+
112
+ - The reference size is the declared occupancy's `max − min` per axis, at the applied size and the resting state. The
113
+ board's 2D box is its XZ projection; the 3D object stands in it. A declared reach (a robot arm's 4700 mm) is the box.
114
+ - Every figure is resized. With `size.x/y/z` it is evaluated again with them (held to their range); without them it is
115
+ scaled in proportion, one scale on X, Y and Z. A proportional figure (no size inputs, or `sizing: 'uniform'`) keeps
116
+ its proportions, and shape, occupancy and box take the same scale.
117
+ - A state change never resizes the box: the occupancy is state-independent (§2–3), so the box does not follow the
118
+ drawing of the current pose.
119
+
110
120
  ## 4. Completion criterion for this contract
111
121
 
112
122
  - The four providers' `@2` forms compile, evaluate, and their `@1` forms are unchanged (the operator-list guard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatiolab/figure-model",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "The figure format (figure-asset-1): a kernel that evaluates a figure from its declared relations, the authoring commands a modeller and an AI share, the release gate and the score.",
5
5
  "keywords": [
6
6
  "figure",
@@ -49,6 +49,7 @@
49
49
  "build": "tsc -p tsconfig.build.json",
50
50
  "check": "tsc -p tsconfig.json --noEmit",
51
51
  "test": "npm run check && node --test src/*.test.ts",
52
+ "test:coverage:slot-layout": "node --test --experimental-test-coverage --test-coverage-include=src/slot-layout.ts --test-coverage-include=src/slot-places.ts --test-coverage-lines=100 --test-coverage-branches=95 --test-coverage-functions=95 src/slot-layout.test.ts src/slot-places.test.ts",
52
53
  "test:rewrite": "node --test scripts/*.test.ts",
53
54
  "test:released": "node --test scripts/released-kernel.check.ts",
54
55
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=95 --test-coverage-functions=100 src/*.test.ts",