Unified additions/deletions diff (#96)

* Draft: WIP observer

* Clean up, only one react root

* Typescript 4.9.5

* Clean up

* Better name

* WIP: csg dependencies, error in console

* WIP: working common/additions, no colors yet

* Working colors

* Prepare for toggle between side by side and unified

* Fix position, add 5% transparency for unchanged

* Clean up

* Toggle to enable unified or side by side

* Clean up and better material for standard view

* Update src/components/diff/CadDiff.tsx

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>

* Remove irrelevant comment

* Make Viewer3D take children

* Introduce BaseModel for camera view stuff

* Comment and clean up

* Remove check needed by an old test

* Add 'Experimental' Beaker icon to Unified button

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
This commit is contained in:
Pierre Jacquier
2023-05-10 04:49:18 -04:00
committed by GitHub
parent ebc40ea14e
commit 8c192e04f8
10 changed files with 17288 additions and 12667 deletions

4219
.pnp.cjs generated

File diff suppressed because it is too large Load Diff

2
.pnp.loader.mjs generated
View File

@ -1946,7 +1946,7 @@ async function resolvePrivateRequest(specifier, issuer, context, nextResolve) {
conditions: new Set(context.conditions),
readFileSyncFn: tryReadFile
});
if (resolved instanceof URL) {
if (resolved instanceof URL$1) {
return { url: resolved.href, shortCircuit: true };
} else {
if (resolved.startsWith(`#`))

0
.yarnrc.yml Normal file
View File

View File

@ -10,6 +10,7 @@
"@octokit/types": "^9.1.1",
"@primer/octicons-react": "^18.3.0",
"@primer/react": "^35.25.0",
"@react-three/csg": "^2.2.0",
"@react-three/drei": "^9.66.0",
"@react-three/fiber": "^8.12.2",
"@testing-library/jest-dom": "^5.14.1",
@ -30,6 +31,7 @@
"react-scripts": "5.0.1",
"styled-components": "^5.3.10",
"three": "^0.151.3",
"three-mesh-bvh": "^0.5.23",
"typescript": "^4.9.5"
},
"scripts": {
@ -72,6 +74,7 @@
]
},
"devDependencies": {
"@babel/runtime": "^7.21.0",
"@playwright/test": "^1.32.3",
"dotenv": "^16.0.3",
"eslint": "^8.39.0",

View File

@ -0,0 +1,52 @@
import { useThree } from '@react-three/fiber'
import type { MutableRefObject, PropsWithChildren } from 'react'
import { Suspense, useEffect, useRef } from 'react'
import { BufferGeometry } from 'three'
import { Vector3 } from 'three'
import { calculateFovFactor } from './Camera'
type BaseModelProps = {
cameraRef: MutableRefObject<any>
geometry: BufferGeometry
}
export function BaseModel({
geometry,
cameraRef,
children,
}: PropsWithChildren<BaseModelProps>) {
const groupRef = useRef<any>()
const camera = useThree(state => state.camera)
const canvasHeight = useThree(state => state.size.height)
// Camera view, adapted from KittyCAD/website
useEffect(() => {
if (geometry && cameraRef.current) {
geometry.computeBoundingSphere()
// TODO: understand the implications of this,
// it's been disabled as it was causing before and after to be misaligned
// geometry.center()
// move the camera away so the object fits in the view
const { radius } = geometry.boundingSphere || { radius: 1 }
if (!camera.position.length()) {
const arbitraryNonZeroStartPosition = new Vector3(0.5, 0.5, 1)
camera.position.copy(arbitraryNonZeroStartPosition)
}
const initialZoomOffset = 7.5
camera.position.setLength(radius * initialZoomOffset)
// set zoom for orthographic Camera
const fov = 15 // TODO fov shouldn't be hardcoded
const fovFactor = calculateFovFactor(fov, canvasHeight)
camera.zoom = fovFactor / camera.position.length()
camera.updateProjectionMatrix()
}
}, [geometry, camera, cameraRef, canvasHeight])
return (
<Suspense fallback={null}>
<group ref={groupRef}>{children}</group>
</Suspense>
)
}

View File

@ -0,0 +1,57 @@
import type { MutableRefObject } from 'react'
import { useTheme } from '@primer/react'
import { BufferGeometry } from 'three'
import { Geometry, Base, Subtraction, Intersection } from '@react-three/csg'
import { BaseModel } from './BaseModel'
type BeforeAfterModelProps = {
beforeGeometry: BufferGeometry
afterGeometry: BufferGeometry
cameraRef: MutableRefObject<any>
}
export function BeforeAfterModel({
beforeGeometry,
afterGeometry,
cameraRef,
}: BeforeAfterModelProps) {
const { theme } = useTheme()
const commonColor = theme?.colors.fg.default
const additionsColor = theme?.colors.success.fg
const deletionsColor = theme?.colors.danger.fg
return (
// TODO: here we give beforeGeometry for auto camera centering,
// for the lack of something better. Need to check the implications
<BaseModel geometry={beforeGeometry} cameraRef={cameraRef}>
{/* Unchanged */}
<mesh>
<meshPhongMaterial
color={commonColor}
transparent
opacity={0.95}
/>
<Geometry>
<Base geometry={beforeGeometry} />
<Intersection geometry={afterGeometry} />
</Geometry>
</mesh>
{/* Additions */}
<mesh>
<meshPhongMaterial color={additionsColor} />
<Geometry>
<Base geometry={afterGeometry} />
<Subtraction geometry={beforeGeometry} />
</Geometry>
</mesh>
{/* Deletions */}
<mesh>
<meshPhongMaterial color={deletionsColor} />
<Geometry>
<Base geometry={beforeGeometry} />
<Subtraction geometry={afterGeometry} />
</Geometry>
</mesh>
</BaseModel>
)
}

View File

@ -1,25 +1,84 @@
import React, { useEffect, useState } from 'react'
import '@react-three/fiber'
import { Box, useTheme, Text } from '@primer/react'
import {
Box,
useTheme,
Text,
TabNav,
StyledOcticon,
} from '@primer/react'
import { FileDiff } from '../../chrome/types'
import { Viewer3D } from './Viewer3D'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { BufferGeometry, Mesh } from 'three'
import { WireframeColors } from './WireframeModel'
import { BufferAttribute, BufferGeometry, Mesh } from 'three'
import { WireframeColors, WireframeModel } from './WireframeModel'
import { Buffer } from 'buffer'
import { useRef } from 'react'
import { BeforeAfterModel } from './BeforeAfterModel'
import { BeakerIcon } from '@primer/octicons-react'
function loadGeometry(file: string, checkUV = false): BufferGeometry {
const loader = new OBJLoader()
const buffer = Buffer.from(file, 'base64').toString()
const group = loader.parse(buffer)
console.log(`Model ${group.id} loaded`)
const geometry = (group.children[0] as Mesh)?.geometry
if (checkUV && !geometry.attributes.uv) {
// UV is needed for @react-three/csg
// see: github.com/KittyCAD/diff-viewer-extension/issues/73
geometry.setAttribute(
'uv',
new BufferAttribute(new Float32Array([]), 1)
)
}
return geometry
}
function Loader3DBeforeAfter({
before,
after,
}: {
before: string
after: string
}) {
const cameraRef = useRef<any>()
const [beforeGeometry, setBeforeGeometry] = useState<BufferGeometry>()
const [afterGeometry, setAfterGeometry] = useState<BufferGeometry>()
useEffect(() => {
setBeforeGeometry(loadGeometry(before, true))
}, [before])
useEffect(() => {
setAfterGeometry(loadGeometry(after, true))
}, [after])
return beforeGeometry && afterGeometry ? (
<Viewer3D cameraRef={cameraRef} geometry={beforeGeometry}>
<BeforeAfterModel
beforeGeometry={beforeGeometry}
afterGeometry={afterGeometry}
cameraRef={cameraRef}
/>
</Viewer3D>
) : (
<Box p={3}>
<Text>Sorry, the rich diff can't be displayed for this file.</Text>
</Box>
)
}
function Loader3D({ file, colors }: { file: string; colors: WireframeColors }) {
const cameraRef = useRef<any>()
const [geometry, setGeometry] = useState<BufferGeometry>()
useEffect(() => {
const loader = new OBJLoader()
const buffer = Buffer.from(file, 'base64').toString()
const group = loader.parse(buffer)
console.log(`Model ${group.id} loaded`)
const geometry = (group.children[0] as Mesh)?.geometry
setGeometry(geometry)
setGeometry(loadGeometry(file))
}, [file])
return geometry ? (
<Viewer3D geometry={geometry} colors={colors} />
<Viewer3D cameraRef={cameraRef} geometry={geometry}>
<WireframeModel
geometry={geometry}
cameraRef={cameraRef}
colors={colors}
/>
</Viewer3D>
) : (
<Box p={3}>
<Text>Sorry, the rich diff can't be displayed for this file.</Text>
@ -28,6 +87,8 @@ function Loader3D({ file, colors }: { file: string; colors: WireframeColors }) {
}
export function CadDiff({ before, after }: FileDiff): React.ReactElement {
const canShowUnified = before && after
let [showUnified, setShowUnified] = useState(false)
const { theme } = useTheme()
const beforeColors: WireframeColors = {
face: theme?.colors.fg.default,
@ -40,24 +101,80 @@ export function CadDiff({ before, after }: FileDiff): React.ReactElement {
dashEdge: theme?.colors.success.subtle,
}
return (
<Box display="flex" height={300} overflow="hidden" minWidth={0}>
{before && (
<Box flexGrow={1} minWidth={0} backgroundColor="danger.subtle">
<Loader3D file={before} colors={beforeColors} />
</Box>
)}
{after && (
<>
<Box display="flex" height={300} overflow="hidden" minWidth={0}>
{canShowUnified && showUnified && (
<Loader3DBeforeAfter before={before} after={after} />
)}
{!showUnified && (
<>
{before && (
<Box
flexGrow={1}
minWidth={0}
backgroundColor="danger.subtle"
>
<Loader3D file={before} colors={beforeColors} />
</Box>
)}
{after && (
<Box
flexGrow={1}
minWidth={0}
backgroundColor="success.subtle"
borderLeftWidth={1}
borderLeftColor="border.default"
borderLeftStyle="solid"
>
<Loader3D file={after} colors={afterColors} />
</Box>
)}
</>
)}
</Box>
{canShowUnified && (
<Box
flexGrow={1}
minWidth={0}
backgroundColor="success.subtle"
borderLeftWidth={1}
borderLeftColor="border.default"
borderLeftStyle="solid"
pt={2}
backgroundColor="canvas.default"
borderTopWidth={1}
borderTopColor="border.default"
borderTopStyle="solid"
borderBottomLeftRadius={6}
borderBottomRightRadius={6}
overflow="hidden"
>
<Loader3D file={after} colors={afterColors} />
<TabNav
aria-label="Rich diff types"
sx={{
display: 'block',
marginX: 'auto',
marginBottom: '-1px',
width: 'fit-content',
}}
>
<TabNav.Link
selected={!showUnified}
onClick={() => setShowUnified(false)}
sx={{ cursor: 'pointer' }}
>
Side-by-side
</TabNav.Link>
<TabNav.Link
selected={showUnified}
onClick={() => setShowUnified(true)}
sx={{ cursor: 'pointer' }}
>
Unified
<StyledOcticon
icon={BeakerIcon}
color="fg.muted"
sx={{ pl: 1 }}
aria-label="Experimental"
/>
</TabNav.Link>
</TabNav>
</Box>
)}
</Box>
</>
)
}

View File

@ -1,27 +1,23 @@
import { useRef } from 'react'
import { MutableRefObject, PropsWithChildren } from 'react'
import '@react-three/fiber'
import { BufferGeometry } from 'three'
import { Canvas } from '@react-three/fiber'
import { WireframeColors, WireframeModel } from './WireframeModel'
import { Camera } from './Camera'
import { CameraControls } from './CameraControls'
type Props = {
type Viewer3DProps = {
cameraRef: MutableRefObject<any>
geometry: BufferGeometry
colors: WireframeColors
}
export function Viewer3D({ geometry, colors }: Props) {
const cameraRef = useRef<any>()
export function Viewer3D({
cameraRef,
geometry,
children,
}: PropsWithChildren<Viewer3DProps>) {
return (
<Canvas dpr={[1, 2]} shadows>
{typeof window !== 'undefined' && geometry && (
<WireframeModel
geometry={geometry}
cameraRef={cameraRef}
colors={colors}
/>
)}
{children}
<CameraControls cameraRef={cameraRef} />
{geometry && <Camera geometry={geometry} />}
</Canvas>

View File

@ -1,9 +1,8 @@
import { useThree } from '@react-three/fiber'
import type { MutableRefObject } from 'react'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import { useMemo, useRef } from 'react'
import { BufferGeometry, DoubleSide } from 'three'
import { EdgesGeometry, Vector3 } from 'three'
import { calculateFovFactor } from './Camera'
import { EdgesGeometry } from 'three'
import { BaseModel } from './BaseModel'
export type WireframeColors = {
face: string
@ -19,33 +18,6 @@ type Props = {
export function WireframeModel({ geometry, cameraRef, colors }: Props) {
const groupRef = useRef<any>()
const camera = useThree(state => state.camera)
const canvasHeight = useThree(state => state.size.height)
// Camera view
useEffect(() => {
if (geometry && cameraRef.current) {
geometry.computeBoundingSphere()
geometry.center()
// move the camera away so the object fits in the view
const { radius } = geometry.boundingSphere || { radius: 1 }
if (!camera.position.length()) {
const arbitraryNonZeroStartPosition = new Vector3(0.5, 0.5, 1)
camera.position.copy(arbitraryNonZeroStartPosition)
}
const initialZoomOffset = 7.5
camera.position.setLength(radius * initialZoomOffset)
// set zoom for orthographic Camera
const fov = 15 // TODO fov shouldn't be hardcoded
const fovFactor = calculateFovFactor(fov, canvasHeight)
camera.zoom = fovFactor / camera.position.length()
camera.updateProjectionMatrix()
}
}, [geometry, camera, cameraRef, canvasHeight])
// Edges for wireframe
const edgeThresholdAngle = 10
const edges = useMemo(
() => new EdgesGeometry(geometry.center(), edgeThresholdAngle),
@ -53,14 +25,14 @@ export function WireframeModel({ geometry, cameraRef, colors }: Props) {
)
return (
<Suspense fallback={null}>
<BaseModel geometry={geometry} cameraRef={cameraRef}>
<group ref={groupRef}>
<mesh
castShadow={true}
receiveShadow={true}
geometry={geometry}
>
<meshBasicMaterial
<meshPhongMaterial
color={colors.face}
side={DoubleSide}
depthTest={true}
@ -83,6 +55,6 @@ export function WireframeModel({ geometry, cameraRef, colors }: Props) {
<lineBasicMaterial color={colors.edge} depthTest={true} />
</lineSegments>
</group>
</Suspense>
</BaseModel>
)
}

25393
yarn.lock

File diff suppressed because it is too large Load Diff