@hraness/slopcamera 3.3.1 → 3.3.3

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.
@@ -18,7 +18,7 @@ import {
18
18
  import { SpatialAssetFactsV1Schema, SpatialCollisionProxySchema, type SpatialAssetFactsV1, type SpatialCollisionProxy, type SpatialRetainedArtifact } from "./asset-admission.js"
19
19
  import { mulberry32 } from "./build.js"
20
20
  import {
21
- SPATIAL_GEOMETRY_PROFILE, SpatialGeometryGraphSchema, emitSpatialGeometryGlb, evaluateSpatialGeometry, estimateSpatialGeometryGraph, parseSpatialGeometryGraph,
21
+ SPATIAL_GEOMETRY_PROFILE, SPATIAL_GEOMETRY_BOOLEAN_COMPILER, SpatialGeometryGraphSchema, emitSpatialGeometryGlb, evaluateSpatialGeometry, estimateSpatialGeometryGraph, parseSpatialGeometryGraph,
22
22
  type SpatialGeometryMesh,
23
23
  } from "./geometry.js"
24
24
  import {
@@ -68,6 +68,8 @@ const specVec3 = z.tuple([coordinate, coordinate, coordinate])
68
68
  const editable = z.array(z.enum(["color", "opacity", "transform"])).max(3).optional()
69
69
  const partPlacement = { transform: SpatialTransformSchema.optional(), editable, castShadow: z.boolean().optional(), receiveShadow: z.boolean().optional() }
70
70
 
71
+ /** center is [horizontal center, sill height from the wall base]. Arch height
72
+ * includes the semicircular crown, whose radius is half its width. */
71
73
  const opening = z.discriminatedUnion("kind", [
72
74
  z.strictObject({ kind: z.literal("rect"), center: specVec2, width: meter, height: meter }),
73
75
  z.strictObject({ kind: z.literal("arch"), center: specVec2, width: meter, height: meter }),
@@ -155,6 +157,9 @@ function boxBounds(bounds: Bounds): SpatialCollisionProxy {
155
157
  }
156
158
 
157
159
  const LOD1_DISTANCE_M = 25
160
+ // Wall lowering changed while the retained GLB/facts format remains v1. Keep
161
+ // the revision local to this planner so unrelated generators retain identity.
162
+ export const SPATIAL_PARAMETRIC_WALL_COMPILER = "slopcamera.parametric-wall-v2" as const
158
163
 
159
164
  function planWall(spec: Extract<SpatialParametricSpec, { kind: "wall" }>): PartPlan[] {
160
165
  const { length, height, thickness } = spec
@@ -165,19 +170,33 @@ function planWall(spec: Extract<SpatialParametricSpec, { kind: "wall" }>): PartP
165
170
  if (cx - item.width / 2 < -length / 2 || cx + item.width / 2 > length / 2 || baseY < 0 || baseY + item.height > height) {
166
171
  pfail(`Wall opening ${index} must stay inside the wall rectangle.`, "spec.openings")
167
172
  }
168
- const rectId = `cut${index}`
169
- cutterNodes.push({ id: rectId, kind: "box", size: [item.width, item.height, thickness * 4] })
170
- cutterNodes.push({ id: `${rectId}p`, kind: "transform", input: rectId, transform: { position: [cx, baseY + item.height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
171
- cutterIds.push(`${rectId}p`)
173
+ const cutterId = `cut${index}`
174
+ // The body is still centered until the final lift. Authored opening Y is
175
+ // measured from its base, so cutters must share the body's local frame.
176
+ let cutterY = baseY + item.height / 2 - height / 2
172
177
  if (item.kind === "arch") {
173
178
  const radius = item.width / 2
174
179
  if (radius > item.height) pfail(`Arch opening ${index} rise exceeds its height.`, "spec.openings")
175
- const cylId = `cyl${index}`
176
- // Half-cylinder lintel: cylinder rotated so its axis runs through the wall thickness.
177
- cutterNodes.push({ id: cylId, kind: "cylinder", radius, height: thickness * 4, segments: 24 })
178
- cutterNodes.push({ id: `${cylId}r`, kind: "transform", input: cylId, transform: { position: [cx, baseY + item.height - radius, 0], rotation: [Math.SQRT1_2, 0, 0, Math.SQRT1_2], scale: [1, 1, 1] } })
179
- cutterIds.push(`${cylId}r`)
180
+ const springline = item.height - radius
181
+ // One extruded cutter has a flat sill and a semicircular crown. A full
182
+ // circle would cut below a shallow opening, while a full-height box
183
+ // would remove the masonry that should remain at the arch shoulders.
184
+ const points: number[][] = [[-radius, 0], [radius, 0]]
185
+ if (springline > 0) points.push([radius, springline])
186
+ for (let segment = 1; segment <= 12; segment++) {
187
+ const angle = Math.PI * segment / 12
188
+ points.push(segment === 12 ? [-radius, springline] : [radius * Math.cos(angle), springline + radius * Math.sin(angle)])
189
+ }
190
+ // At height == radius the arc ends at the first profile point.
191
+ if (springline === 0) points.pop()
192
+ cutterNodes.push({ id: `${cutterId}profile`, kind: "profile", points })
193
+ cutterNodes.push({ id: cutterId, kind: "extrude", profile: `${cutterId}profile`, depth: thickness * 4 })
194
+ cutterY = baseY - height / 2
195
+ } else {
196
+ cutterNodes.push({ id: cutterId, kind: "box", size: [item.width, item.height, thickness * 4] })
180
197
  }
198
+ cutterNodes.push({ id: `${cutterId}p`, kind: "transform", input: cutterId, transform: { position: [cx, cutterY, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
199
+ cutterIds.push(`${cutterId}p`)
181
200
  }
182
201
  const nodes: unknown[] = [{ id: "body", kind: "box", size: [length, height, thickness] }]
183
202
  let output = "body"
@@ -534,6 +553,8 @@ export interface SpatialParametricArtifact { readonly assetId: string; readonly
534
553
  export interface SpatialParametricFacts { readonly manifest: SpatialAssetManifest; readonly facts: SpatialAssetFactsV1 }
535
554
  export interface SpatialParametricReceipt {
536
555
  readonly name?: string
556
+ readonly compiler?: typeof SPATIAL_PARAMETRIC_WALL_COMPILER
557
+ readonly booleanCompiler?: typeof SPATIAL_GEOMETRY_BOOLEAN_COMPILER
537
558
  readonly kind: "slopcamera.spatial-parametric-receipt"
538
559
  readonly schemaVersion: 1
539
560
  readonly generatorId: string
@@ -580,14 +601,19 @@ export function emitSpatialParametric(input: unknown): SpatialParametricOutput {
580
601
  outputs: [...outputs],
581
602
  })
582
603
  }
583
- const sourceSha256 = spatialValueSha256({ domain: "slopcamera.parametric-source.v1", profile: SPATIAL_GEOMETRY_PROFILE, kind: spec.kind })
584
- const parametersSha256 = spatialGeneratorParametersSha256(spec)
585
- const specSha256 = spatialValueSha256({ domain: "slopcamera.parametric-spec.v1", spec })
586
- const seed = request.seed ?? deriveSpatialGeneratorSeed(sourceSha256)
587
- const runtimeSha256 = spatialValueSha256({ domain: "slopcamera.parametric-runtime.v1", profile: SPATIAL_GEOMETRY_PROFILE })
588
604
  const plans = PLANNERS[spec.kind](spec as never)
589
605
  if (plans.length > SPATIAL_PARAMETRIC_LIMITS.parts) pfail("Spec expands beyond the part budget.", "spec")
590
606
  validatePartMaterials(plans)
607
+ const usesBoolean = plans.some(part => part.lods.some(lod => parseSpatialGeometryGraph(lod.graph).nodes.some(node => node.kind === "boolean")))
608
+ const compiler: Pick<SpatialParametricReceipt, "compiler" | "booleanCompiler"> = {
609
+ ...(spec.kind === "wall" ? { compiler: SPATIAL_PARAMETRIC_WALL_COMPILER } : {}),
610
+ ...(usesBoolean ? { booleanCompiler: SPATIAL_GEOMETRY_BOOLEAN_COMPILER } : {}),
611
+ }
612
+ const sourceSha256 = spatialValueSha256({ domain: "slopcamera.parametric-source.v1", profile: SPATIAL_GEOMETRY_PROFILE, kind: spec.kind, ...compiler })
613
+ const parametersSha256 = spatialGeneratorParametersSha256(spec)
614
+ const specSha256 = spatialValueSha256({ domain: "slopcamera.parametric-spec.v1", spec })
615
+ const seed = request.seed ?? deriveSpatialGeneratorSeed(sourceSha256)
616
+ const runtimeSha256 = spatialValueSha256({ domain: "slopcamera.parametric-runtime.v1", profile: SPATIAL_GEOMETRY_PROFILE, ...compiler })
591
617
  const manifests: SpatialAssetManifest[] = []
592
618
  const artifacts: SpatialParametricArtifact[] = []
593
619
  const entities: SpatialEntity[] = []
@@ -682,6 +708,7 @@ export function emitSpatialParametric(input: unknown): SpatialParametricOutput {
682
708
  generatorId, specSha256, parametersSha256, seed,
683
709
  ...(request.name === undefined ? {} : { name: request.name }),
684
710
  profile: SPATIAL_GEOMETRY_PROFILE,
711
+ ...compiler,
685
712
  assets: receiptAssets,
686
713
  entities: entities.map(entity => entity.entityId).sort(),
687
714
  native: retainedArtifacts.map(artifact => ({ requestSha256: artifact.requestSha256, receiptSha256: artifact.receiptSha256 })),
@@ -76,7 +76,7 @@ export const SPATIAL_PERFORMANCE_BODY_MASKS = Object.freeze({
76
76
 
77
77
  export type SpatialPerformanceBodyMaskName = keyof typeof SPATIAL_PERFORMANCE_BODY_MASKS
78
78
 
79
- export const SPATIAL_PERFORMANCE_COMPILER_ID = "slopcamera.spatial-performance-compiler@v2"
79
+ export const SPATIAL_PERFORMANCE_COMPILER_ID = "slopcamera.spatial-performance-compiler@v3"
80
80
 
81
81
  const humanoidBoneName = z.enum(HUMANOID_BONE_NAMES)
82
82
  const unit = z.number().finite().min(0).max(1)
@@ -1150,15 +1150,15 @@ export function compileSpatialPerformance(planInput: unknown, sourcesInput: unkn
1150
1150
  const order = mappedBoneOrder(sources.mapping)
1151
1151
 
1152
1152
  const morphDirectives = plan.directives.filter((d) => d.kind === "morph") as Extract<SpatialPerformancePlan["directives"][number], { kind: "morph" }>[]
1153
- const attachDirectives = plan.directives.filter((d) => d.kind === "attach") as Extract<SpatialPerformancePlan["directives"][number], { kind: "attach" }>[]
1154
- const releaseDirectives = plan.directives.filter((d) => d.kind === "release") as Extract<SpatialPerformancePlan["directives"][number], { kind: "release" }>[]
1153
+ const attachmentDirectives = plan.directives.filter((d) => d.kind === "attach" || d.kind === "release")
1154
+ .sort((a, b) => a.startUs - b.startUs || Number(a.kind === "attach") - Number(b.kind === "attach"))
1155
1155
  const springDirectives = plan.directives.filter((d) => d.kind === "spring") as Extract<SpatialPerformancePlan["directives"][number], { kind: "spring" }>[]
1156
1156
  const lookAtDirectives = plan.directives.filter((d) => d.kind === "look-at") as Extract<SpatialPerformancePlan["directives"][number], { kind: "look-at" }>[]
1157
1157
  const twoBoneDirectives = plan.directives.filter((d) => d.kind === "two-bone-ik") as Extract<SpatialPerformancePlan["directives"][number], { kind: "two-bone-ik" }>[]
1158
1158
  const footPlantDirectives = plan.directives.filter((d) => d.kind === "foot-plant") as Extract<SpatialPerformancePlan["directives"][number], { kind: "foot-plant" }>[]
1159
1159
  const rootTrajectory = plan.directives.find((d) => d.kind === "root-trajectory") as Extract<SpatialPerformancePlan["directives"][number], { kind: "root-trajectory" }> | undefined
1160
1160
 
1161
- const propIds = new Set<string>([...Object.keys(sources.props), ...attachDirectives.map((d) => d.propId), ...releaseDirectives.map((d) => d.propId)])
1161
+ const propIds = new Set<string>([...Object.keys(sources.props), ...attachmentDirectives.map((d) => d.propId)])
1162
1162
 
1163
1163
  type MutableSample = {
1164
1164
  timeUs: number
@@ -1307,22 +1307,22 @@ export function compileSpatialPerformance(planInput: unknown, sourcesInput: unkn
1307
1307
  let attached = false
1308
1308
  let parentBone: string | undefined
1309
1309
  let localOffset: { position: [number, number, number]; rotation: [number, number, number, number]; scale: [number, number, number] } | undefined
1310
- for (const directive of attachDirectives) {
1311
- if (directive.propId === propId && timeUs >= directive.startUs && timeUs < directive.endUs) {
1312
- attached = true
1313
- parentBone = directive.bone
1314
- localOffset = {
1315
- position: [directive.localOffset.position[0], directive.localOffset.position[1], directive.localOffset.position[2]],
1316
- rotation: [directive.localOffset.rotation[0], directive.localOffset.rotation[1], directive.localOffset.rotation[2], directive.localOffset.rotation[3]],
1317
- scale: [directive.localOffset.scale[0], directive.localOffset.scale[1], directive.localOffset.scale[2]],
1318
- }
1319
- }
1310
+ // Ownership follows the latest event, including expired attaches so an
1311
+ // older interval cannot resume. At one instant, release closes the old
1312
+ // binding before attach opens the new one. Stable sorting preserves
1313
+ // authored precedence among simultaneous attaches.
1314
+ let latest: (typeof attachmentDirectives)[number] | undefined
1315
+ for (const directive of attachmentDirectives) {
1316
+ if (directive.startUs > timeUs) break
1317
+ if (directive.propId === propId) latest = directive
1320
1318
  }
1321
- for (const directive of releaseDirectives) {
1322
- if (directive.propId === propId && timeUs >= directive.startUs) {
1323
- attached = false
1324
- parentBone = undefined
1325
- localOffset = undefined
1319
+ if (latest?.kind === "attach" && timeUs < latest.endUs) {
1320
+ attached = true
1321
+ parentBone = latest.bone
1322
+ localOffset = {
1323
+ position: [latest.localOffset.position[0], latest.localOffset.position[1], latest.localOffset.position[2]],
1324
+ rotation: [latest.localOffset.rotation[0], latest.localOffset.rotation[1], latest.localOffset.rotation[2], latest.localOffset.rotation[3]],
1325
+ scale: [latest.localOffset.scale[0], latest.localOffset.scale[1], latest.localOffset.scale[2]],
1326
1326
  }
1327
1327
  }
1328
1328
  let worldPosition: [number, number, number] = [0, 0, 0]
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SLOPCAMERA_VERSION = "3.3.1" as const
1
+ export const SLOPCAMERA_VERSION = "3.3.3" as const